Compare commits

..

1014 Commits

Author SHA1 Message Date
sjg cc64c51fd0 [feat](trx-frontend-http): make map source filter select exclusively on click
Clicking a source chip now isolates that source instead of toggling it.
Clicking the already-isolated source restores default visibility.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-18 07:18:23 +01:00
sjg 723da3f7ed [fix](trx-frontend-http): make decode history lists fill remaining viewport
Replace the fixed max-height: 360px on FT8/FT4/FT2/WSPR message
containers with flex-based layout so they grow to fill the available
space. Make #content, .tab-panel, and .sub-tab-panel flex containers
that propagate height down the layout chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 23:20:49 +01:00
sjg 7cf829ef52 [feat](trx-rs): display APRS-IS connection status on About page
Thread aprs_is_status through RigState, RigSnapshot, and the protocol
layer following the same pattern as pskreporter_status. Show the
connection target and callsign when enabled, or "Disabled" otherwise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 23:20:43 +01:00
sjg 2517ed0b29 [fix](trx-frontend): fix spectrum screenshot hotkey
Preserve the WebGL drawing buffers used by the spectrum snapshot,
flush them before compositing, and move the shortcut listener to
capture phase so focused widgets do not swallow it.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 23:11:10 +01:00
sjg 9019acee0e [feat](trx-backend-soapysdr): enable hardware AGC by default if available
Query the device for AGC support via has_gain_mode and enable it
automatically at startup. Devices without hardware AGC fall back to
manual gain as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:57:20 +01:00
sjg cf4c262456 [fix](trx-wspr): reduce false positives with stricter validation
Restrict accepted power levels to the 19 valid WSPR values instead of
any 0-60. Require a digit at position 1 or 2 of the trimmed callsign
per the WSPR encoding rules. Skip candidates whose sync correlation
score falls below a minimum threshold before attempting Fano decode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:57:13 +01:00
sjg 7527770c0c [fix](trx-frontend): let decoder disable take scheduler control
When a scheduler-managed decoder is manually disabled from the frontend,
take scheduler control first so the manual change overrides the current
scheduler cycle like a direct frequency change does.

Track decoder enabled state on the toggle buttons and only take over
when the click is actually disabling FT8, FT4, FT2, WSPR, or HF APRS.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:39:00 +01:00
sjg b533d704a1 [style](trx-rs): reformat codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:36:11 +01:00
sjg a823e66816 [fix](trx-server): drop in-flight decodes on main retune
Invalidate main-decoder windows whenever the rig's main frequency
changes, including direct SetFreq, scheduler-driven retunes, and
external CAT retunes observed during polling.

The decoder loops now resubscribe their PCM receivers on reset and drop
results that finish after the reset sequence advances, preventing false
decodes from stale pre-retune audio.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:28:31 +01:00
sjg 7044747ade [fix](trx-wspr): remove spurious power field offset that rejected all decodes
The WSPR 7-bit power field contains the raw dBm value (0-60) with no
offset. The decoder was subtracting 64, turning valid power values into
negative numbers that always failed the range check, causing
unpack_message to return None for every real signal. Also fix callsign
trimming to strip leading spaces from space-padded callsigns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:26:55 +01:00
sjg da799a1d1f [fix](trx-frontend): widen main panel on large screens
Allow the main frontend card to grow past the previous 1280px cap
on large screens while keeping side gutters for bookmark stacks.

Only widen the desktop layout at 1440px+ and cap it at 1600px so the
main content stays readable with room for side bookmarks.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 22:07:36 +01:00
sjg bcd3255ad7 [fix](trx-wspr): fix reversed deinterleaving that prevented all decodes
The deinterleave function had its indices swapped: it wrote
out[j] = symbols[p] instead of out[p] = symbols[j]. This fed
completely scrambled data to the Fano decoder, making convergence
impossible. Matched against the reference implementation in
raptor/lib/wsprd/wsprd_utils.c which does tmp[p] = sym[j].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:48:06 +01:00
sjg 46415fa307 [fix](trx-ft8): add hard-error verification to FT2 OSD decoder
The OSD-lite decoder was the source of FT2 false positives. It tries
685 CRC-14 checks across 5 passes (1 + 16 + 120 per pass), giving a
~4% chance of accepting random noise as a valid decode.

The reference implementation (decode174_91) verifies OSD results
against the received signal; the trx-rs OSD-lite only checked CRC.

Add ft2_count_hard_errors_vs_llr() which counts how many of the 174
coded bits in an OSD candidate disagree with the received hard
decisions. A legitimate correction disagrees in very few positions;
a false CRC match on noise disagrees in ~40-50 parity positions.
Reject OSD results with more than 36 hard errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:44:21 +01:00
sjg 8aa1884d2d [fix](trx-ft8): align FT2 decoder thresholds and LLR scaling with reference
The FT2 decoder was failing to decode valid signals due to thresholds
and LLR parameters that were significantly stricter than the reference
implementation (Decodium/raptor ft2_triggered_decode).

Thresholds aligned with reference at depth>=3:
- Peak detection: 1.08 -> 1.03 (reference uses 0.50 on normalized spectrum)
- Scan sync: 0.60 -> 0.50 (reference at depth>=3)
- Decode sync: 0.80 -> 0.65 (reference at depth>=3)
- Sync quality min: 13 -> 10 (reference at depth>=3)

LLR parameters aligned:
- Scale factor: 2.83 -> 3.2 (matches reference scalefac)
- Normalization: sqrt(24/var) -> 1/sigma (matches reference normalizebmet)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:38:48 +01:00
sjg 7844cb65c8 [fix](trx-ft8): add missing encode.c to build
The ft8_wrapper.c references ft4_encode and ft8_encode from encode.c,
but encode.c was not included in build.rs, causing linker errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:29:42 +01:00
sjg c71fc58e3e [fix](trx-wspr): add sync vector correlation and multi-candidate decoding
The WSPR decoder was producing almost no valid decodes despite audible
signals. Three root causes:

- No sync vector: the 162-bit WSPR sync pattern was not used, so signal
  detection relied on raw peak power which is unreliable.
- Coarse frequency search: 4 Hz steps with 1.465 Hz tone spacing could
  miss signals entirely. Now uses 2 Hz coarse + 0.25 Hz fine refinement.
- Fixed timing: assumed signal starts exactly 1s into the slot. Now
  searches +/-2s in 0.5s steps to handle real-world timing jitter.

Also evaluates up to 8 frequency/timing candidates per slot and reports
the actual measured timing offset in dt_s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:24:50 +01:00
sjg 4464fa3735 [fix](trx-ft8): replace adjacent-bin SNR with post-decode estimation
The previous SNR formula (cand->score * 0.5 - 29.0) used the adjacent
tone bin as a noise reference. On a crowded FT8 band that bin is often
occupied by another station, inflating the apparent noise floor by
10-15 dB and capping reported SNR at around -10 dB even for strong
signals.

Replace with ftx_post_decode_snr(): re-encode the decoded message to
obtain the exact per-symbol tone sequence, compare each signal bin
against the minimum of the remaining (noise-only) bins, average over
all valid symbols, and apply the WSJT-X 2500 Hz bandwidth correction
dynamically per protocol. This produces accurate SNR estimates for both
FT8 and FT4 regardless of band occupancy.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 21:06:43 +01:00
sjg e1a9a8717f [style](trx-frontend-http): fix picker widths in scheduler entry form
Primary bookmark was cramped at one grid column; Extra channels was
unnecessarily spanning the full row. Swap their grid-column spans so
the bookmark select gets full width and Extra channels sits in a
normal column alongside Label and Interleave.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:43:21 +01:00
sjg 49659a5ce7 [style](trx-frontend-http): increase spacing in scheduler entry form
Row gap 0.5→0.75rem and label-to-input gap 0.2→0.35rem, scoped
to #sch-entry-form so the bookmark form is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:42:42 +01:00
sjg d6d7c7d1f0 [style](trx-frontend-http): make scheduler entry Edit/Remove buttons smaller
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:41:57 +01:00
sjg 8da4c49d1d [feat](trx-frontend-http): align scheduler entry form with bookmark modal
Replace the inline always-visible add-row with a modal overlay
(same fixed + blurred-backdrop pattern as the bookmark add/edit
form). The "+ Add Entry" button opens the modal; each row now has
Edit and Remove buttons. Edit pre-fills the modal and updates the
entry in-place on save. CSS reuses the existing bookmark modal
selectors rather than duplicating rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:40:37 +01:00
sjg fc423d9958 [docs](trx-rs): drop FLAC support from recorder plan
Opus is the sole audio format. Removes all FLAC references, the
format config key, the claxon/flac dependency row, and the
FLAC-encoder open question.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:29:42 +01:00
sjg 659f18143d [docs](trx-rs): add recorder feature plan
Captures requirements REQ-REC-001–006, REQ-PLAY-001–002, and
REQ-SYNC-001 into a structured design document covering the new
trx-recorder crate, session layout (FLAC/Opus audio + JSONL data
track + seek index), command API, playback engine, and phased
implementation plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:27:42 +01:00
sjg bff3be38be [feat](trx-backend-soapysdr): implement ARM NEON optimizations
Add NEON (aarch64) vectorized paths mirroring the existing AVX2 paths:

- demod/math_arm.rs: replace the no-op placeholder with a full NEON FM
  discriminator that processes 4 samples per iteration using a 7th-order
  minimax atan polynomial and branchless atan2 with argument reduction,
  matching the accuracy of the AVX2 path (max error ~2.4e-7 rad).
  32-bit ARM retains the scalar fallback.

- dsp/filter.rs: add mul_freq_domain_neon() that deinterleaves 4 complex
  pairs via vuzpq/vzipq, performs complex multiply with vmulq/vaddq/vsubq,
  then reinterleaves. On aarch64 this path is always taken (NEON is
  mandatory); scalar fallback remains for other targets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:22:56 +01:00
sjg 088f05050c [feat](trx-frontend-http): add strongest decoded signal summary to map
Add a new "Strongest decoded signal" section below the existing
"Longest decode paths" section on the map tab, showing the top 5
stations with the highest SNR across the current map history window.
Reuses existing map-qso-card styling with SNR displayed in place of
distance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:12:15 +01:00
sjg 4c69273584 [feat](trx-wspr): implement WSPR protocol decoder
Replace the always-None stub with a full decode pipeline:
- Bit-reversal deinterleave of 4-FSK symbols (data_bit = symbol >> 1)
- Fano sequential decoder for K=32, rate-1/2 convolutional code
  (polynomials 0xF2D05351 / 0xE4613C47, 100k-cycle budget)
- Payload unpack: 28-bit callsign (mixed-radix N1), 15-bit Maidenhead
  grid (M1 formula), 7-bit power code (dBm + 64)
- Validity checks on callsign, grid, and power range
- Round-trip unit test for K1JT FN20 37

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 01:04:38 +01:00
sjg 7a144375f2 [fix](trx-backend-soapysdr): enforce 9 kHz minimum bandwidth for AMC mode
clamp_bandwidth_for_mode now floors AMC bandwidth at 9 kHz so that both
the sum (L+R) and difference (L−R) sidebands are always captured,
regardless of what the user or a set_filter call requests.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:19:35 +01:00
sjg e337870d53 [fix](trx-backend-soapysdr): add AMC to supported_modes capabilities list
RigMode::AMC was implemented but omitted from the supported_modes vec,
so the HTTP frontend never received it in capabilities and the mode
selector did not show AMC-QUAM.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:15:47 +01:00
sjg 016d4ddfd7 [fix](trx-rs): resolve all clippy warnings
- remote_client tests: add missing server_connected field and import
  AtomicBool in the test module
- pskreporter: replace map_or(true, …) with is_none_or and
  repeat(x).take(n) with repeat_n(x, n)
- dsp.rs, scheduler.rs: suppress intentional too_many_arguments with
  #[allow(clippy::too_many_arguments)]

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:15:03 +01:00
sjg 6eb2d5341f [fix](trx-backend-ft450d): mark AMC unsupported in FT-450D encoder
Add a RigMode::AMC arm to encode_mode returning an Err so that
attempting to set C-QUAM on the FT-450D returns a descriptive error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:10:48 +01:00
sjg bdc581637c [fix](trx-backend-ft817): mark AMC unsupported in FT-817 encoder
Add RigMode::AMC to the None return arm of encode_mode so that
attempting to set C-QUAM on the FT-817 returns a descriptive error
rather than a compile-time non-exhaustive match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:10:45 +01:00
sjg c818139175 [feat](trx-frontend-http): add AMC-QUAM to mode selector
Add "AMC-QUAM" to the MODE_BW_DEFAULTS table in app.js with the same
bandwidth settings as AM (9 kHz default, 500 Hz min, 20 kHz max, 500 Hz
step). Add an <option value="AMC-QUAM"> to the bookmark datalist in
index.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:10:40 +01:00
sjg 223726368d [feat](trx-server): handle AMC mode in rig_task and server config
Add RigMode::AMC arm to default_audio_bandwidth_for_mode, returning
9_000 Hz (same as AM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:10:34 +01:00
sjg 3564a48ced [feat](trx-backend-soapysdr): implement C-QUAM stereo demodulator
Add CquamDemod (demod/amcquam.rs) with first-order IIR carrier phase
tracker (τ = 50 ms) that rotates baseband IQ to align I with the sum
audio and Q with the difference audio, then DC-blocks each channel to
yield L/R stereo PCM.

Wire AmCQuam into the Demodulator enum, add ChannelDsp::cquam_decoder
field initialized for RigMode::AMC, and insert the C-QUAM audio path
between the WFM and fallback branches in process_block. Update all
mode-dispatch tables (agc_for_mode, iq_agc_for_mode, dc_for_mode,
default_bandwidth_for_mode, lib.rs, vchan_impl.rs) with AMC arms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:10:31 +01:00
sjg 6be8644d45 [feat](trx-protocol): serialize AMC mode as "AMC-QUAM"
Add parse_mode and mode_to_string support for RigMode::AMC, accepting
"AMC-QUAM", "AMC_QUAM", and "AMC" as input strings and serializing to
"AMC-QUAM". Add test cases for round-trip correctness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:10:20 +01:00
sjg 52452b609d [feat](trx-core): add AMC variant to RigMode for C-QUAM AM stereo
Add RigMode::AMC to represent Motorola C-QUAM Compatible Quadrature
Amplitude Modulation stereo, placing it after RigMode::AM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-17 00:10:15 +01:00
sjg 844b0e60df [fix](trx-frontend-http): roll back optimistic freq update when set_freq fails
setRigFrequency applied applyLocalTunedFrequency before the network
round-trip, leaving the spectrum view shifted to the new frequency even
when the POST was rejected (e.g. 403 when the user loses control access).
Save the previous frequency and restore it in the catch block so the UI
stays aligned with the actual server-side state.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:46:04 +01:00
sjg 9fba303bd8 [feat](trx-frontend-http): add connection-lost overlay for trx-client and trx-server failures
Show a full-screen blurred overlay (reusing decode-history-overlay styles)
when the SSE connection to trx-client drops or when trx-server is reported
unreachable via server_connected=false.  The overlay distinguishes the two
failure modes with separate titles and sub-text.  It is dismissed on
es.onopen (trx-client back) or when a message with server_connected!=false
arrives (trx-server back), and cleared on explicit disconnect/logout.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:44:18 +01:00
sjg 84dc28cf77 [fix](trx-frontend-http): dismiss decode history overlay on connection error
decodeSource.onerror terminated the history worker but never called
flushLiveBuffer(), leaving historySettled=false and the "Loading decode
history…" overlay stuck on screen when trx-client breaks.  Call
flushLiveBuffer() in the error path so the overlay is dismissed and the
powerHint connection-lost message is visible to the user.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:41:18 +01:00
sjg 0f57fb8920 [fix](trx-frontend-http): fix axis bookmark chip positioning after name span addition
querySelectorAll("span") was picking up the inner .spectrum-bookmark-name
spans added in the previous commit, causing chips[i] to map to the wrong
element during left-position assignment.  Switch to :scope > span to
select only direct-child chip spans.

Also revert axis bar from height:auto (which breaks the CSS transition and
collapses the bar) to height:38px with overflow:visible to accommodate
two-line labels while keeping the open/close animation intact.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:37:11 +01:00
sjg 4a12d79f92 [feat](trx-frontend-http): disable path animations above threshold; wrap axis bookmark labels
Suppress stroke-dashoffset animation and drop-shadow filter on all
contact/radio paths when decodeContactPaths.size > 20 by toggling
.map-paths-static on #aprs-map, avoiding per-frame GPU compositing
with large decode histories.

Wrap non-sideStack bookmark chip labels in
<span class="spectrum-bookmark-name"> and allow word-break on axis
chips so long names split across two lines instead of being clipped.
Axis bar switches to min-height so it grows to fit taller chips.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:33:26 +01:00
sjg d131efae36 [feat](trx-frontend-http): show distinct connection loss messages in UI
When the SSE stream drops (onerror or 15s heartbeat timeout) show
"trx-client connection lost, retrying…". When the stream is live but
server_connected is false, show "trx-server connection lost".

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:27:54 +01:00
sjg 9058e5d101 [feat](trx-frontend-http): expose server_connected in SSE and status JSON
Add server_connected bool to FrontendMeta and inject it into every
/status and /events payload so the browser can distinguish between
trx-client and trx-server connection loss.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:27:49 +01:00
sjg 1b6ae1b18c [feat](trx-client): set server_connected around trx-server TCP session
Store server_connected in RemoteClientConfig and set it true when
handle_connection begins, false when it returns. Wire the Arc clone
from FrontendRuntimeContext into the config at startup.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:27:44 +01:00
sjg 1f4c3e0384 [feat](trx-frontend): add server_connected flag to FrontendRuntimeContext
Track whether the remote client currently has an active TCP connection
to trx-server via a shared AtomicBool. Frontends can read this to
surface a distinct "trx-server connection lost" message.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:27:38 +01:00
sjg efa51443b6 [fix](trx-frontend-http): truncate long bookmark labels with ellipsis
Side-panel chips used white-space: normal + word-break: break-word,
causing names longer than ~16 characters to wrap onto a second line.
Switch to nowrap + overflow: hidden + text-overflow: ellipsis so long
names are clipped cleanly in a single line.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:20:29 +01:00
sjg 338c75c13d [fix](trx-frontend-rigctl): add missing ft4/ft2 fields in test fixture
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:17:57 +01:00
sjg e15e822a1f [fix](trx-frontend-http-json): add missing ft4/ft2 fields in test fixture
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:17:57 +01:00
sjg 6700e79155 [refactor](trx-frontend-http): remove /set_fir_taps endpoint
Drop FirTapsQuery, the set_fir_taps handler, and its service
registration. Tap count is no longer user-configurable.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:17:47 +01:00
sjg c2d6530b83 [refactor](trx-server): remove fir_taps from SDR config and rig_task
Drop fir_taps field from SdrChannelConfig and its default. Remove the
SetFirTaps dispatch arm from rig_task. The DSP layer now auto-calculates
tap count from audio_bandwidth_hz.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:17:32 +01:00
sjg 37a5600d99 [refactor](trx-backend-soapysdr): auto-calculate FIR taps from bandwidth
Replace the static fir_taps parameter with auto_taps(cutoff_norm) which
computes ceil(3.32 / cutoff_norm).clamp(63, 16383). This ensures the
filter transition band equals one passband width regardless of SDR sample
rate, giving correct image rejection when the user sets audio_bandwidth_hz.

At 912 ksps with 3 kHz audio bandwidth this yields ~2018 taps instead
of the previous hardcoded 64, eliminating the 114 kHz stopband gap that
caused adjacent-band signals to alias into the audio output.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:17:23 +01:00
sjg 1a8bfb3c4e [refactor](trx-protocol): remove SetFirTaps from client protocol
Drop SetFirTaps ClientCommand variant and its mapping to/from
RigCommand. Remove fir_taps from RigFilterState codec tests.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:17:10 +01:00
sjg 0dc6761fa0 [refactor](trx-core): remove SetFirTaps command and fir_taps from state
Drop the SetFirTaps RigCommand variant, remove the set_fir_taps default
trait method from Rig, and remove the fir_taps field from RigFilterState.
Tap count is now derived automatically from audio bandwidth in the DSP
layer rather than being a user-facing control.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-16 23:17:01 +01:00
sjg 5aa3d61ce0 [feat](trx-backend-soapysdr): seed LNA gain from hardware at init
Add read_named_gain to IqSource (default: None) and implement it in
RealIqSource via Device::gain_element. Read the "LNA" element before
boxing the source so the initial sdr_lna_gain_db reflects the actual
hardware state, making the UI control visible and correct on first
connect. Devices without an LNA element (e.g. RTL-SDR with "TUNER")
return None and the control stays hidden.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-15 19:29:46 +01:00
sjg b7a4b8a1df [feat](trx-frontend-http): add LNA gain control UI and API
Add POST /set_sdr_lna_gain endpoint. Add an LNA Gain input+Set button
to the SDR settings row, styled identically to RF Gain. The control is
hidden until the server reports a sdr_lna_gain_db value (i.e. devices
that expose an LNA gain element). AGC disables it alongside RF Gain.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-15 19:25:15 +01:00
sjg 2ebd085aec [feat](trx-server): dispatch SetSdrLnaGain in rig_task
Handle RigCommand::SetSdrLnaGain by calling set_sdr_lna_gain on the
rig and refreshing filter state, matching the pattern used by
SetSdrGain and SetSdrAgc.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-15 19:25:09 +01:00
sjg 2a298add7d [feat](trx-backend-soapysdr): implement LNA gain element control
Add set_named_gain to the IqSource trait and implement it in
RealIqSource via soapysdr Device::set_gain_element. Wire a
lna_gain_cmd channel through SdrPipeline so the IQ read loop applies
LNA gain changes on the next iteration. Add set_sdr_lna_gain to
SoapySdrRig and expose the current value via filter_state.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-15 19:25:04 +01:00
sjg fb715860fb [feat](trx-protocol): add SetSdrLnaGain client command
Map ClientCommand::SetSdrLnaGain { gain_db } to and from
RigCommand::SetSdrLnaGain in both directions.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-15 19:24:57 +01:00
sjg db71ca0950 [feat](trx-core): add SetSdrLnaGain command and sdr_lna_gain_db state
Add RigCommand::SetSdrLnaGain(f64), the corresponding default RigCat
trait method set_sdr_lna_gain, and an sdr_lna_gain_db: Option<f64>
field to RigFilterState for backends that expose a named LNA gain
element.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-15 19:24:52 +01:00
sjg 2702696032 [fix](trx-frontend-http): fix Hardware AGC control styling in SDR settings
Replace vol-label with wfm-control on the Hardware AGC checkbox so its
label typography and alignment match the RF Gain control. Change the
outer container to align-items: flex-end so controls bottom-align.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-15 19:24:37 +01:00
sjg 22311eb01c [fix](trx-frontend-http): drop bidirectional requirement for decode paths
The bidirectional check required both A→B and B→A directed messages to
draw a contact line, which was too strict — the receiver may not hear
both sides of a QSO. Now a decode path is drawn whenever a directed
message is decoded and the target's locator is known from any message
in the 24 h history window.

Also rename "Longest QSOs" → "Longest decode paths" and update
related UI labels to better reflect what is actually shown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 17:09:12 +01:00
sjg 8cb713b3b1 [fix](trx-frontend-http): require bidirectional decodes for map QSO contact paths
A QSO requires both parties to hear each other. Previously the map drew
contact paths whenever A sent a directed message to B and B's locator
was known from any message. Now paths only appear when both A→B and B→A
directed messages are present in the decoded history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 16:53:46 +01:00
sjg 3d99cac03b [feat](trx-backend-soapysdr): add hardware AGC toggle and SDR settings UI row
Add hardware AGC on/off control for SoapySDR backend, wired through the
full stack from RigCommand to the web UI:

- RigCommand::SetSdrAgc(bool) + ClientCommand::SetSdrAgc in protocol
- set_sdr_agc() on RigCat trait (not-supported default)
- SoapySdrRig: agc_enabled field, set_sdr_agc() via pipeline agc_cmd,
  sdr_agc_enabled in filter_state(); removes the "not yet implemented"
  warning — gain_mode="auto" now properly enables hardware AGC via
  SoapySDR set_gain_mode()
- IqSource::set_gain_mode() trait method; RealIqSource implements it
- SdrPipeline: agc_cmd channel, read loop applies it each iteration
- POST /set_sdr_agc endpoint in trx-frontend-http
- New "SDR settings" full-row in index.html with Hardware AGC checkbox
  and RF Gain (moved out of WFM controls); row hidden when
  show_sdr_gain_control is false
- app.js: AGC checkbox handler, disables RF gain input while AGC is on,
  syncs checkbox state from filter.sdr_agc_enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 09:44:46 +01:00
sjg 262e78e72b [fix](trx-backend-soapysdr): fix AM demodulation DC blocker, AGC, and add IQ AGC
Three issues caused audible distortion on AM reception:

1. DC blocker shared r=0.9999 (τ≈1.25 s at 8 kHz) across all modes.
   For AM the envelope detector outputs A_c+m(t) — always positive —
   so the blocker needs to track the carrier bias quickly.  AM now uses
   r=0.999 (τ≈125 ms), 10× faster, while keeping the highpass cutoff
   below 2 Hz so speech is unaffected.

2. Audio AGC time constants were inverted relative to good AM AGC design:
   attack=200 ms (should be fast to prevent overload) and
   release=3500 ms (unreasonably sluggish).  Changed to attack=5 ms /
   release=200 ms, target=0.5, max_gain=36 dB.

3. No IQ AGC before envelope detection meant carrier amplitude variation
   went directly into the audio chain, forcing the slow audio AGC to
   handle both RF level and audio level simultaneously.  Added an AM IQ
   AGC (attack=0.5 ms, release=50 ms, target=0.7, max=30 dB) that
   normalizes carrier power before demod_am, so the DC blocker always
   sees the same steady-state bias regardless of signal strength.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 09:28:59 +01:00
sjg f78232562d [fix](trx-frontend-http): add 200-row DOM cap notice to FT8/FT4/FT2 tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 08:50:30 +01:00
sjg b1f52bbfa5 [fix](trx-frontend-http): fix FT4/FT2/WSPR message container styling and cap DOM rows
Apply #ft8-messages container style (border, rounded corners, monospace
font, max-height with scroll) to #ft4-messages, #ft2-messages, and
#wspr-messages which were missing it.

Add #ft4-decode-toggle-btn and #ft2-decode-toggle-btn to the narrow-
screen white-space:nowrap media query rule alongside FT8/WSPR.

Cap DOM rows rendered per history view to 200 (FT8_MAX_DOM_ROWS,
FT4_MAX_DOM_ROWS, FT2_MAX_DOM_ROWS). Full history is retained in
memory; only the DOM representation is bounded. This prevents tab
switching from becoming sluggish after a long decode session where
thousands of rows accumulate in the DOM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 08:46:14 +01:00
sjg ab3bf9120e [fix](trx-backend-soapysdr): fix SSB demodulation with asymmetric complex BPF
Three bugs caused USB/LSB to sound like AM:

1. The IQ low-pass filter was symmetric (passband ±BW/2), so both
   sidebands were passed equally — taking .re then produced DSB-SC
   rather than SSB audio.

2. cutoff_hz was computed as bandwidth_hz/2, halving the usable audio
   bandwidth (1500 Hz for a 3 kHz USB channel).

3. demod_lsb claimed spectrum inversion was "handled upstream by
   negating channel_if_hz", but that negation was never applied; USB
   and LSB were functionally identical.

Fix: add a shift_norm parameter to build_fir_kernel / BlockFirFilterPair
that complex-modulates the time-domain FIR coefficients by
e^{j·2π·shift_norm·n}, shifting the passband in the frequency domain.
A new ssb_shift_norm() helper returns +cutoff_norm for USB/CW/DIG
([0, BW] Hz passband) and -cutoff_norm for LSB/CWR ([-BW, 0] Hz
passband); all other modes get 0.0 (symmetric LPF unchanged).

After the one-sided filter, taking .re correctly reconstructs the
selected sideband. No IF negation is needed for LSB.

Also fix two unit tests missing the force_mono_pcm argument introduced
after they were last updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 08:35:33 +01:00
sjg 342adf476c [fix](trx-ft8): limit FT2 OSD-lite to prevent false decodes
The OSD-3 (triples) path over 5 LLR passes was doing ~11,600 CRC checks
per candidate. With a 14-bit CRC this gives ~0.7 expected false positives
per candidate — far too high.

Remove OSD-3 entirely. Cap max_candidates at 16 for OSD-1/OSD-2, giving
136 CRC checks per pass (680 total). Gate OSD-lite behind a check that
LDPC reached within 6 parity errors of converging, so it only fires when
the LLRs are already trustworthy. Combined false-positive rate drops to
~0.04 per near-miss candidate.

Also remove the now-unused ft2_osd_decode and ft2_codeword_distance
functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 08:14:22 +01:00
sjg eac2efdb35 [fix](trx-ft8): increase FT2 LDPC iterations and improve OSD fallback
Increase BP/SP iteration count from 30 to 50 to match WSJT-X reference
and give belief propagation more opportunities to converge near-threshold
candidates.

Replace the parity-based OSD-1/OSD-2 fallback (which required LDPC to
have nearly converged) with ft2_osd_lite_decode applied to all five LLR
combination passes. The CRC-based decoder works directly from raw LLRs
without depending on LDPC convergence, searching the 24 least-reliable
systematic bits for up to three bit errors via OSD-3.

Also increase max_candidates in ft2_osd_lite_decode from 12 to 24 for
broader coverage of likely error positions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 08:06:19 +01:00
sjg 3b28281684 [fix](trx-ft8): add OSD-1/OSD-2 to FT2 LDPC decode path
Diagnostic logging showed the FT2 BP/SP decoders consistently reach
1-8 residual parity errors rather than zero — the LLRs are correct
in direction but LDPC belief propagation stalls just short of
convergence.

Add ft2_osd_decode() implementing Ordered Statistics Decoding orders
1 and 2: after the five-pass BP/SP loop fails, sort the 174 codeword
bits by |LLR| ascending and trial-flip single bits (OSD-1, always)
or all pairs of the 50 least-reliable bits (OSD-2, when the remaining
error count is <= 4).  Each trial costs one O(83) parity check;
worst-case overhead is ~1300 checks per candidate, negligible next to
the 5 x 30-iteration BP/SP passes already performed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 07:30:17 +01:00
sjg 0d2eb7adcd [fix](trx-ft8): log minimum LDPC parity errors per pass in FT2 window
Add err=N/N/N/N/N to the FT2 window diagnostic log line, showing the
minimum number of unsatisfied parity equations across all candidates
for each of the five LLR passes. This makes it possible to distinguish
between a signal-quality-limited failure (small error count) and a
systematic decoder bug (large error count), which is the key unknown
in diagnosing the current FT2 LDPC non-convergence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-15 07:21:38 +01:00
sjg 188fa38f1b [fix](trx-ft8): improve FT2 decode fallback
Normalize FT2 log-likelihoods before LDPC and fall back to\nthe standard waterfall candidate decoder when the raw FT2\npath produces no decodes.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 22:39:07 +01:00
sjg 71bfc5fca1 [fix](trx-rs): route FT2 decode through shared LDPC path 2026-03-14 22:24:16 +01:00
sjg 5dd8f4112a [fix](trx-rs): isolate FT2 OSD LDPC from shared ft8_lib 2026-03-14 22:10:49 +01:00
sjg d2257fd8a3 [fix](trx-rs): widen FT4 candidate timing search window
Use a wider FT4 time-offset search range in candidate acquisition\nso decode remains robust with current pipeline latency.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 21:55:30 +01:00
sjg 7c08e12870 [fix](trx-rs): fix FT4 7.5s slot rollover timing
Use millisecond-based slot indexing for FT4 decode windows in\nforeground and background decoders to avoid premature 7s resets.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 21:50:26 +01:00
sjg cededd9c3f [fix](trx-rs): align FT2 downsample path with reference
Reuse a shared FT2 downsample FFT context per decode window and\nfix filter shift/bin handling to match the reference implementation.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 21:45:35 +01:00
sjg dc683792de [fix](trx-rs): match FT2 BP transfer function 2026-03-14 21:32:32 +01:00
sjg 4efb0dc5ef [fix](trx-rs): correct FT2 symbol bin indexing 2026-03-14 21:29:26 +01:00
sjg 86d1775484 [fix](trx-rs): correct FT2 OSD basis vectors 2026-03-14 21:25:36 +01:00
sjg 2ad555efd1 [debug](trx-rs): trace FT2 decode pass outcomes 2026-03-14 21:22:29 +01:00
sjg f43f3a27fd [fix](trx-rs): correct FT2 OSD generator setup 2026-03-14 21:18:13 +01:00
sjg e6a78d0ec0 [feat](trx-rs): port FT2 hybrid LDPC decoder 2026-03-14 21:15:20 +01:00
sjg 0b97329f63 [fix](trx-rs): normalize FT2 bitmetrics before LDPC 2026-03-14 21:05:55 +01:00
sjg 5b168fd6d5 [debug](trx-rs): add FT2 decode stage logging 2026-03-14 21:02:02 +01:00
sjg 3c538d5712 [fix](trx-rs): correct FT2 sync phase progression 2026-03-14 20:46:42 +01:00
sjg d3abce9ab0 [fix](trx-rs): align FT2 async window geometry 2026-03-14 20:44:25 +01:00
sjg 2ab05f5001 [fix](trx-rs): move FT2 decoding onto raw complex path 2026-03-14 20:40:11 +01:00
sjg 6d430a4300 [fix](trx-rs): add FT2 raw-window candidate search
Use an FT2-specific raw sample window and candidate acquisition path.
Keep the build clean by removing the stale monitor warning.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 20:32:21 +01:00
sjg 27de75cf79 [fix](trx-rs): improve FT2 coherent decoding
Add coherent FT2 sync and likelihood extraction.
Align FT2 frequency and DT reporting with the reference decoder.
Fix vendored monitor phase-mode type mismatches.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 20:25:59 +01:00
sjg 2570fe739e [fix](trx-rs): improve FT2 decoder acquisition
Use FT2-specific analysis settings and a wider receive span.
Switch server-side FT2 decoding to a rolling async window.
Widen FT2 candidate timing search in the vendored decoder.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 20:20:18 +01:00
sjg b032473801 [feat](trx-frontend): simplify decoder history overlays
Move full-history clear actions into Settings > History.
Remove decoder pause controls and pause-only buffering paths.
Add close controls to live overlay bars and fix FT4/FT2 overlay naming.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 20:13:55 +01:00
sjg ad6aa6aab4 [feat](trx-rs): add FT2 decoder protocol support
Implement a distinct FT2 protocol path in the decoder stack and align\nits timing with the confirmed FT2 framing used by Decodium.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 19:49:32 +01:00
sjg d547c45a9c [feat](trx-rs): add FT2 decoder support (wired to FT4)
Mirrors the FT4 implementation across the full stack. The trx-ft8
crate wires Ft8Decoder::new_ft2() to FTX_PROTOCOL_FT4 as a
placeholder pending a dedicated FT2 implementation.

Changes:
- trx-ft8: Ft8Decoder::new_ft2() delegates to with_protocol(Ft4)
- trx-core: DecodedMessage::Ft2, AUDIO_MSG_FT2_DECODE (0x15),
  ft2_decode_enabled/ft2_decode_reset_seq state, SetFt2DecodeEnabled/
  ResetFt2Decoder commands, protocol mapping
- trx-server: DecoderHistories::ft2, run_ft2_decoder (7.5s slots),
  run_background_ft2_decoder, history push/replay, decoder task spawn
- trx-frontend-http: ft2_history in FrontendRuntimeContext,
  toggle/clear endpoints, /ft2.js route, bookmark/scheduler/background
  decode support, DecodeHistoryPayload ft2 field
- web: ft2.js plugin (3.75s period timer), FT2 subtab in index.html,
  FT2 map source (distinct hue), app.js dispatch, decode-history-worker
  HISTORY_GROUP_KEYS, bookmarks read/write/apply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 19:34:41 +01:00
sjg 708c00a84c [refactor](trx-frontend-http): rename ft8MapAddLocator to mapAddLocator
The function handles FT8, FT4, and WSPR locators so the ft8 prefix
was misleading. Updated all call sites in app.js, ft8.js, ft4.js,
and wspr.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 18:58:57 +01:00
sjg 9b8ea37a6e [feat](trx-frontend-http): add FT4 as map source
- Add ft4 to DEFAULT_MAP_SOURCE_FILTER (visible by default)
- Add ft4 hue to locatorThemeHues (peakHue + 30° offset from FT8)
- Propagate ft4 through locatorSourceLabel, locatorFilterColor,
  locatorHueForEntry, isLocatorOverlay, applyMapFilter,
  ensureDecodeLocatorMarker, pruneLocatorEntry, rebuildDecodeContactPaths,
  clearMapMarkersByType, markerSearchText, navigateToMapLocator, and
  the source items chip row
- ft8MapAddLocator now maps type="ft4" to markerType="ft4" so FT4
  locators get their own distinct marker colour and filter chip
- ft4.js: pass "ft4" (not "ft8") to ft8MapAddLocator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 18:57:58 +01:00
sjg 780e68089f [docs](trx-frontend-http): add FT4 decoder entry to Overview tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 18:55:05 +01:00
sjg 5156296444 [fix](trx-frontend-http): add FT4 to bookmark decoder read/write/apply
bmReadDecoders, bmWriteDecoders, and bmApply were all missing FT4,
so the decoder checkbox was never saved and tuning a bookmark never
toggled the FT4 decoder state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 18:54:41 +01:00
sjg 8eae376c56 [feat](trx-rs): add FT4 decoder support
Reuse the existing ft8_lib C library (FTX_PROTOCOL_FT4) and FT8
decoder infrastructure to add FT4 decoding across the full stack.

Changes:
- trx-ft8: add protocol param to ft8_decoder_create; add Ft8Decoder::new_ft4()
- trx-core: DecodedMessage::Ft4 variant, AUDIO_MSG_FT4_DECODE (0x14),
  ft4_decode_enabled/ft4_decode_reset_seq state, SetFt4DecodeEnabled/
  ResetFt4Decoder commands, protocol mapping
- trx-server: DecoderHistories::ft4, run_ft4_decoder (7.5s slots via
  now*2/15), run_background_ft4_decoder, history push/replay, decoder
  task spawn
- trx-frontend-http: ft4_history in FrontendRuntimeContext,
  toggle/clear endpoints, /ft4.js route, bookmark/scheduler/background
  decode support, DecodeHistoryPayload ft4 field
- web: ft4.js plugin (7.5s period timer, reuses FT8 CSS/map infra),
  FT4 subtab in index.html, app.js dispatch (onServerFt4/Batch,
  restoreFt4History), decode-history-worker HISTORY_GROUP_KEYS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 18:50:08 +01:00
sjg a1b9b7f762 [fix](trx-frontend): keep QSO summary visible
Decouple the longest-QSO summary from the contact path render toggle.
Keep the summary filtered by current map visibility while the toggle only
controls whether path overlays are drawn.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-14 18:20:15 +01:00
sjg 73b1d5618d [fix](trx-frontend-http): group decode history by decoder
Serve grouped decode history payloads and restore each decoder through
explicit history restore hooks instead of replaying a mixed message stream.

This reduces replay overhead further by removing type regrouping and keeping
history restoration on decoder-specific bulk paths.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 18:11:09 +01:00
sjg cba3751f0e [fix](trx-frontend-http): move history decode off main thread
Serve a dedicated decode-history worker and move compressed history fetch
and CBOR parsing into that worker.

The main thread now drains ready-made decode batches within a frame budget,
which further reduces UI disruption during large history restores.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 18:04:11 +01:00
sjg e9cb2852be [fix](trx-frontend-http): restore longest qso card styling
Override the global button chrome on longest-QSO cards so they keep
the intended card layout instead of inheriting fixed control sizing.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 17:57:57 +01:00
sjg abae110ecd [fix](trx-frontend-http): batch decode history replay
Replay decode history in decoder-specific batches instead of feeding every
message through the single-message path.

This reduces per-message array churn and UI scheduling during large history
loads while keeping the existing live decode behavior unchanged.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 17:56:28 +01:00
sjg 4114e0b9fa [fix](trx-frontend-http): speed up decode history replay
Serve decode history as gzipped CBOR and decode it in the frontend.
Defer map materialization until replay completes to avoid replay-time stutter,
and include the pending longest-QSO style adjustment.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 17:47:49 +01:00
sjg a924902074 [feat](trx-client): add configurable decode history retention
Add a default decode history retention setting in minutes with per-rig overrides, resolve the active rig retention in the HTTP frontend runtime, and apply that retention consistently across backend decode history buffers and frontend decoder views. This removes fixed APRS, HF APRS, AIS, VDES, FT8, and WSPR browser-side history caps in favor of time-based pruning, and includes the pending longest-QSO card style reset.

Verification: cargo test -p trx-client config
Verification: cargo test -p trx-frontend-http --no-run
Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js
Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/hf-aprs.js
Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js
Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vdes.js
Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js
Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js
Verification: git diff --check -- src/trx-client/src/config.rs src/trx-client/src/main.rs src/trx-client/trx-frontend/src/lib.rs src/trx-client/trx-frontend/trx-frontend-http/src/api.rs src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/aprs.js src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/hf-aprs.js src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vdes.js src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ft8.js src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/wspr.js

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 17:18:09 +01:00
sjg 86768c8e7f [fix](trx-frontend-http): focus longest qso map paths
Let users click longest-QSO cards to isolate a single contact path on the map and click again to restore all visible contact paths. Also remove the extra inner panel styling from decode map tooltips so the popup renders as a single container.

Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
Verification: git diff --check -- src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 17:01:42 +01:00
sjg d5c3283b37 [fix](trx-frontend-http): restore map history window state
Keep map history data cached when the history window is reduced so older APRS, AIS, VDES, FT8, and WSPR items can be shown again when the user expands the window, and add a global decode-history replay overlay with progress updates across the UI. Also update the longest QSO summary to render bidirectional contacts with <-> labels.

Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
Verification: git diff --check -- src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 16:58:05 +01:00
sjg e2517ec184 [feat](trx-frontend-http): add longest map qso summary
Add a map summary section below the map that lists the five longest directed FT8 and WSPR contacts in the current view, including distance, band, age, and locator details.

Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
Verification: git diff --check -- src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 16:46:06 +01:00
sjg 3b52fdf232 [fix](trx-frontend-http): add map history limit filter
Add a map filter-panel history picker with 15 minute through 24 hour retention options and prune dynamic APRS, AIS, VDES, FT8, and WSPR overlays to the selected age window.

Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 16:41:14 +01:00
sjg daedd91829 [fix](trx-client): restore decode history replay
Write compressed audio-history replay directly into the local frontend history buffers so large APRS and AIS replays survive trx-client restart instead of overrunning the live decode broadcast channel.

Verification: cargo test -p trx-client --no-run
Verification: cargo test -p trx-frontend-http --no-run

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 14:09:04 +01:00
sjg 3f7afd961b [fix](trx-frontend-http): release scheduler after entry step
Automatically return control to the scheduler after using the Previous or Next entry controls so manual stepping does not leave the session latched in takeover mode.

Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/scheduler.js

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 14:08:44 +01:00
sjg c1e8ce42d2 [fix](trx-frontend-http): start APRS on scheduler PKT channels
Carry bookmark decoder kinds through visible scheduler virtual channels so PKT/APRS scheduler entries start their decode workers instead of acting as audio-only channels.

Verification: cargo test -p trx-frontend-http vchan
Verification: cargo test -p trx-client (fails in existing config::tests::test_default_config)

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 13:54:44 +01:00
sjg 5590ac5c7d [fix](trx-frontend-http): fix scheduler virtual channel selection
Apply scheduler-backed virtual channels as real manual selections so they take control, retune the rig, and restore bookmark decoder state including APRS/PKT. Also remove the inner border from the map decode locator tooltip.\n\nVerification: cargo test -p trx-frontend-http vchan\nVerification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 13:44:51 +01:00
sjg 8603cee7be [fix](trx-frontend-http): sync scheduler channels live
Keep scheduler-managed virtual channels reconciled while\nclients remain connected, instead of only materializing\nthem during the initial connect path.\n\nVerification: cargo test -p trx-frontend-http vchan\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 13:33:57 +01:00
sjg 9e8003afb6 [fix](trx-frontend-http): show scheduler channels on connect
Materialize scheduler-managed virtual channels before the\ninitial channels SSE event when the scheduler currently\ncontrols the rig.\n\nVerification: cargo test -p trx-frontend-http vchan\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 13:30:48 +01:00
sjg c50f716390 [fix](trx-frontend-http): follow active channel in page title
Use the currently tuned virtual channel for the website title\ninstead of always showing channel 0 metadata.\n\nVerification: node --check assets/web/app.js\nVerification: node --check assets/web/plugins/vchan.js\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 13:21:04 +01:00
sjg dca01ead90 [fix](trx-frontend-http): reset scheduler step timing
Track the last applied scheduler entry so previous/next\ncycles correctly across active entries and resets the\ncountdown after manual entry changes.\n\nVerification: cargo test -p trx-frontend-http scheduler\nVerification: node --check assets/web/plugins/scheduler.js\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 13:17:45 +01:00
sjg badf7c0d0f [fix](trx-frontend-http): improve scheduler entry controls
Add previous/next scheduler entry controls for overlapping\ntime-span slots and fix interleave timing calculations so\nthe active slot and countdown align with the overlap window.\n\nVerification: cargo test -p trx-frontend-http scheduler\nVerification: node --check assets/web/plugins/scheduler.js\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 13:11:30 +01:00
sjg c4d1313625 [fix](trx-frontend-http): smooth decode history loading
Reduce main-thread stalls while decode history replays.\n\nCoalesce decoder list redraws and map maintenance so spectrum\nand controls stay responsive during history import.\n\nVerification: node --check on modified frontend JS files.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 12:56:02 +01:00
sjg 28d5494c3b [fix](trx-frontend-http): correct map auth and overlay behavior
Treat the SPA entry routes as public so direct requests to /map,\n/decoders, /settings, and /about return the app shell and let\nthe frontend show the login screen instead of a 403.\n\nMove the map filter overlay to the bottom-right corner and color\ndecode contact paths by their decoded band so they match the band\nlegend and locator overlays.\n\nVerified with cargo test -p trx-frontend-http.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 12:46:08 +01:00
sjg 07c5129668 [feat](trx-reporting): add software comment to APRS beacon
Append the APRS-IS position beacon comment as\n`trx-rs v<version> by SP2SJG` so IGate beacons identify\nthe running software version.\n\nUpdate the APRS beacon formatter tests to assert the exact\npayload including the generated version string.\n\nCo-Authored-By: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 12:37:38 +01:00
sjg 9292bf3830 [fix](trx-ft8): drop FT8 messages that fail to unpack
Skip decoded candidates where ftx_message_decode() returns a non-OK
status instead of forwarding a synthetic error string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 12:33:02 +01:00
sjg b9744ef8fd [feat](trx-frontend-http): color radio paths by band or mode
Radio paths and decode contact paths now use the same color as the
marker they belong to, respecting the active filter mode:
- Band mode: color follows the band (golden-angle HSL hue)
- Mode/source mode: color follows the source type (FT8/WSPR/bookmark)

APRS, AIS, and VDES paths use their fixed source colors unchanged.
Decode contact paths sync color when the filter mode is switched.

CSS stroke/stroke-opacity removed from path classes so Leaflet's
color option takes effect; dasharray and flow animation are retained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 12:31:56 +01:00
sjg a3c098b68f [fix](trx-ft8): correct FT8 SNR to WSJT-X 2500 Hz convention
The score from ft8_lib is an averaged uint8 difference between Costas
sync tones and their neighbours (each unit = 0.5 dB). The previous
score * 0.5 gave the signal-above-adjacent-noise in dB relative to a
single 3.125 Hz waterfall bin, yielding values of +5 to +50 dB —
all wrong.

Subtract 10*log10(2500/3.125) ≈ 29 dB to normalise to the 2500 Hz
reference bandwidth used by WSJT-X and expected by PSKReporter:

  snr = score * 0.5 - 29.0

This maps score 10 (minimum decodable) → -24 dB and score 60 → +1 dB,
matching typical WSJT-X SNR report ranges.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 12:13:03 +01:00
sjg 3c31e16833 [fix](trx-reporting): split beacon_symbol into table and code fields
Replace the two-character beacon_symbol string with separate
beacon_symbol_table (char) and beacon_symbol_code (char) fields to
avoid TOML backslash escaping issues with the alternate symbol table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 12:03:56 +01:00
sjg 40e99d3ea9 [feat](trx-reporting): add APRS-IS position beaconing
Add periodic IGate position beacon support to the APRS-IS uplink.
New AprsFiConfig fields: beacon (bool), beacon_interval_secs (default
1200), beacon_symbol (default "/-"), latitude/longitude overrides.

A beacon is sent immediately on connect then every beacon_interval_secs.
Coordinates fall back from [aprsfi] to [general].latitude/longitude.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 09:49:29 +01:00
sjg 872e086763 [feat](trx-reporting): add callsign field to AprsFiConfig
Allow specifying the IGate callsign directly in [aprsfi] instead of
relying on [general].callsign. The aprsfi-specific callsign takes
precedence; [general].callsign is used as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 09:35:32 +01:00
sjg fed8948a61 [feat](trx-rs): extract trx-reporting crate for uplink tasks
Move aprsfi and pskreporter modules from trx-server into a new
standalone trx-reporting library crate. Config types (AprsFiConfig,
PskReporterConfig) move to trx-reporting and are re-exported from
trx-server::config for backwards compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 09:28:47 +01:00
sjg ecb058a9d2 [fix](trx-server): comply with APRS-IS IGate spec
- Append mandatory q-construct (,qAR,<callsign>) to all forwarded
  TNC2 packets via updated format_tnc2(pkt, igate_call)
- Add TCPIP/TCPXX loop-prevention check before forwarding
- Drain server-sent data in select! loop to prevent TCP backpressure
- Enable TCP_NODELAY for low-latency packet forwarding
- Guard against history replays: skip packets older than 2 minutes
- Use "trx-rs" in login string and keepalive comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 09:24:03 +01:00
sjg 1dec2c2663 [chore](trx-rs): centralize version in workspace.package
Add [workspace.package] version = "0.1.0" to the root Cargo.toml and
switch all 21 member crates to version.workspace = true so the entire
workspace is versioned from a single place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 09:13:06 +01:00
sjg f7d3a935ee [fix](trx-server): use "trx-rs" as PSKReporter software identifier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 09:04:46 +01:00
sjg 587ece9903 [fix](trx-backend-soapysdr): fix audio distortion from incorrect force_mono_pcm
Two bugs introduced by 60697bb:

1. dsp.rs passed `channel_idx == 0` as force_mono_pcm, which forced the
   primary pipeline channel to output mono samples. The Opus encoder was
   configured for stereo, so it received half the expected frame data,
   causing distortion for all connected audio clients.
   Fixed by passing `false` — hidden virtual channels already set
   force_mono_pcm=true via set_force_mono_pcm() in vchan_impl.rs.

2. main.rs short-circuited channel conversion when no audio clients were
   connected, sending raw frames to pcm_tx (decoders). When clients then
   connected, decoders switched to receiving stereo-interleaved frames,
   making decoder input format dependent on client presence.
   Fixed by always performing the channel conversion before sending to
   pcm_tx; the no-client skip now only bypasses Opus encode + rx_audio_tx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 08:53:08 +01:00
sjg 945912adc1 [fix](trx-server): guard APRS-IS uplink against history replays
Skip APRS packets whose ts_ms is older than 120 seconds. Live RF-decoded
packets arrive within milliseconds; history replay items can be up to 24
hours old and must not be re-uploaded to APRS-IS as live traffic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 08:45:24 +01:00
sjg 5ed3e29d90 [fix](trx-server): fix PSKReporter IPFIX packet format and batching
Rewrite the PSKReporter uplink to match the protocol spec exactly:

- Fix template FlowSetIDs: receiver uses 0x0003 (Options Template Set),
  sender uses 0x0002 (Template Set); previously both used 0x9992/0x9993
- Add missing enterprise numbers (0x0000768F = 30351) to all enterprise
  field specifiers in both template blocks
- Fix sender template field IDs: use correct attributes (senderCallsign
  30351.1, frequency 30351.5, sNR 30351.6, iMD 30351.7, mode 30351.10,
  informationSource 30351.11, senderLocator 30351.3, flowStartSeconds 150)
- Fix sender data field order to match the template declaration
- Add iMD byte (0) required by the 8-field template
- Add 4-byte null padding on receiver and sender data records
- Batch spots into one UDP packet per 5-minute window (spec requirement)
- Deduplicate by callsign within each window (keep most-recent spot)
- Send template descriptors only in first 3 packets then once per hour
- Increment sequence number by report count, not packet count
- Guard against history replays: drop any spot older than the flush
  window (live FT8/WSPR is seconds old; history can be 24 h old)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-14 08:33:36 +01:00
sjg 0b0e86f496 [fix](trx-frontend-http): sync map legend with filter mode
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 19:38:34 +01:00
sjg fb83e3cade [feat](trx-frontend-http): default map filtering to band
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 16:31:40 +01:00
sjg cb824d20ec [fix](trx-frontend-http): separate map corner controls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 15:48:28 +01:00
sjg 968dc84997 [fix](trx-frontend-http): honor scheduler explicit center freq
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 15:13:04 +01:00
sjg e4487d9037 [feat](trx-frontend-http): add map filter panel toggle
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 14:29:29 +01:00
sjg a82a7ab668 [feat](trx-frontend-http): overlay map controls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 14:18:18 +01:00
sjg 60697bb26a [perf](trx-server): reduce headless scheduler audio cpu
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 14:18:03 +01:00
sjg 7fde0f7730 [chore](trx-server): remove temporary hang tracing
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 13:50:41 +01:00
sjg 47f57aa10d [fix](trx-server): drop state watch borrows before decode
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 13:37:42 +01:00
sjg bc153b84ee [debug](trx-server): trace rig task progress
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 13:32:38 +01:00
sjg 12f1d81af8 [debug](trx-server): trace backend control flow
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 13:19:21 +01:00
sjg 1be02ec8ad [debug](trx-server): trace listener and rig task activity
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 11:57:34 +01:00
sjg e5bf7346c8 [debug](trx-backend-soapysdr): trace SDR retune application
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 11:31:27 +01:00
sjg fc954fd27c [fix](trx-frontend-http): stop headless background spectrum polling
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 11:28:41 +01:00
sjg 8be8e798c6 [fix](trx-frontend-http): drop orphaned vchans on disconnect
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 08:14:34 +01:00
sjg 8043a5001c [fix](trx-server): move remaining decoders off async workers
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 08:09:16 +01:00
sjg 73173d29ff [fix](trx-backend-soapysdr): keep vchans across scheduler retunes
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 07:38:56 +01:00
sjg c014f5cdc2 [fix](trx-frontend-http): refresh map sources on live APRS
Rebuild the visible-source chips when live APRS, AIS, or VDES markers are first added so the map filter list updates without a page refresh.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 07:24:09 +01:00
sjg 66987a4070 [fix](trx-client): preserve vchan commands under scheduler churn
Use an unbounded virtual-channel command queue so background decode and scheduler transitions do not silently drop subscribe or remove commands.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 07:10:40 +01:00
sjg c178c56f2e [fix](trx-server): clean up hidden vchans on disconnect
Remove hidden background decode channels when the owning audio client disconnects to avoid stale DSP and decoder buildup.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 07:01:09 +01:00
sjg 69fd24577c [fix](trx-server): move heavy decoders off async workers
Run FT8 and WSPR decode steps in blocking sections so the server listener stays responsive under decode load.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 06:56:54 +01:00
sjg 0179c13000 [fix](trx-server): avoid spectrum hang on overflow
Avoid SoapySDR overflow restart wedge and coalesce concurrent GetSpectrum requests.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 06:45:10 +01:00
sjg 4cac91e36a [fix](trx-frontend-http): show scheduler interleave timing
Replace the misleading scheduler task countdown with the actual time-span interleave switch timing in the main controls row.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 01:08:31 +01:00
sjg be4844c710 [fix](trx-client): speed up remote spectrum polling
Raise remote spectrum polling from 100 ms to 50 ms while keeping the relaxed timeout and subscriber gating.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 01:03:38 +01:00
sjg 4f4cd4647d [fix](trx-client): reduce remote spectrum timeout churn
Relax the remote spectrum timeout, poll at the backend update cadence, and stop polling when no spectrum subscribers are connected.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 00:59:59 +01:00
sjg b679ff0282 [feat](trx-frontend-http): refine map and scheduler controls
Add separate map path toggles, move scheduler handoff into the channels row, and show a live countdown to the next scheduler cycle.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 00:52:00 +01:00
sjg 4cca188d9f [feat](trx-frontend-http): improve scheduler and decode map controls
Remove settings rig pickers, restore the last scheduler cycle on release, fix FT8 locator role parsing, and add toggleable decode contact paths on the map.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 00:45:12 +01:00
sjg f7cbc0cb02 [fix](trx-frontend-http): move scheduler handoff out of channels row
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 00:28:25 +01:00
sjg 5c28ed1269 [feat](trx-frontend-http): add scheduler control handoff
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-13 00:22:18 +01:00
sjg f8fd4572c7 [feat](trx-frontend-http): paginate bookmarks tab
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 23:57:02 +01:00
sjg 963d527dfe [feat](trx-frontend-http): add direct tab routes
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 23:54:25 +01:00
sjg bdd3d29374 [fix](trx-frontend-http): show aprs and ais bookmarks in background decode
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 23:03:57 +01:00
sjg 95fccd3da6 [feat](trx-frontend-http): expand background decode selection
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:57:25 +01:00
sjg 34d0ec8ca8 [feat](trx-server): add aprs and ais background decoders
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:57:18 +01:00
sjg a91a1868d8 [fix](trx-frontend-http): refresh background decode settings UI
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:48:24 +01:00
sjg 2462f1dd47 [feat](trx-frontend-http): add background decode settings
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:42:50 +01:00
sjg 21a534bdb6 [feat](trx-client): support background decode subscriptions
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:42:43 +01:00
sjg 763d4c00b0 [feat](trx-server): support hidden background decode channels
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:42:37 +01:00
sjg 6c9ff33d68 [feat](trx-frontend): add background decode audio commands
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:42:33 +01:00
sjg b83558b1a2 [feat](trx-core): add hidden background channel API
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:42:21 +01:00
sjg 2d014ac45b [docs](trx-rs): refresh top-level docs and config example
Rewrite the README, remove AI-generated planning docs, and regenerate the combined example config.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 22:09:34 +01:00
sjg fc24dc37ed [feat](trx-rs): add settings tab and virtual audio plan
Move Scheduler under a new Settings tab in the HTTP frontend.
Add the virtual-channel audio implementation plan document.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 21:55:54 +01:00
sjg 6d18f5e1d4 [fix](trx-frontend): enable BW drag handles on vchans
Use the active channel frequency for spectrum bandwidth edge hit-testing.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 20:49:43 +01:00
sjg e8326b4822 [fix](trx-frontend): align spectrum RDS overlays vertically
Keep virtual-channel RDS overlays on a shared vertical axis.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 20:44:21 +01:00
sjg add0a93424 [feat](trx-frontend-http): frequency-ordered z-index and hover-to-front for RDS overlay layers
Each RDS PS overlay item (position: absolute within the shared #rds-ps-overlay
container) now receives a z-index derived from its channel frequency: items are
sorted by freq_hz ascending so higher-frequency layers sit on top of
lower-frequency ones by default.

Hovering any layer temporarily assigns it the maximum z-index (entry count + 10)
to bring it to the front; mouseleave restores the frequency-derived default
stored in data-default-z.

Also reverts the incorrectly applied vchan picker layer changes from the
previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 20:12:23 +01:00
sjg 1fe7dc88c6 [feat](trx-frontend-http): add frequency layers display for virtual channels
Render virtual channels as absolutely-positioned layer strips inside a
shared relative container (#vchan-freq-layers). Layers are sorted by
frequency ascending so higher-frequency channels receive a higher z-index
and sit on top by default. Hovering any layer temporarily assigns it the
maximum z-index to bring it to the front; leaving restores the original
stacking order. Each layer is offset by 11 px vertically so all channels
remain visible as a staggered card stack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-12 20:04:27 +01:00
sjg 93ff35a824 Add per-channel RDS overlays for WFM vchans 2026-03-11 22:39:02 +01:00
sjg 21972c27d2 [test](trx-client): fix fixtures for updated rig snapshot
Update test fixtures to include hf_aprs_decode_enabled and use the current spectrum watch sender type in remote client tests.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 21:41:39 +01:00
sjg daa0631b35 [feat](trx-client): support virtual channel bandwidth control
Add client-side command plumbing, HTTP endpoint handling, and frontend interception so bandwidth changes are applied per active virtual channel and survive reconnects.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 21:41:32 +01:00
sjg 717228a635 [feat](trx-server): handle virtual channel bandwidth updates
Handle AUDIO_MSG_VCHAN_BW in the audio server path and apply per-channel filter bandwidth through the SoapySDR virtual channel manager.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 21:41:26 +01:00
sjg 502e97049a [feat](trx-core): add virtual channel bandwidth control
Add virtual-channel bandwidth control to the shared core API and audio protocol constants for client/server coordination.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 21:41:17 +01:00
sjg 894739bbab [chore](trx-rs): resolve all compiler and clippy warnings
- trx-server/rig_handle: remove dead vchan_manager field (was set but
  never read after the virtual-channel refactor)
- trx-server/listener: remove now-missing vchan_manager initializer
- trx-server/main: remove vchan_manager_for_handle intermediates that
  only fed the dropped field
- trx-server/audio: suppress too_many_arguments on run_audio_listener
- trx-frontend-http/server: suppress too_many_arguments on build_server
- trx-core/vchan: update module doc comment to not reference the
  removed RigHandle::vchan_manager field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 21:01:38 +01:00
sjg 19ab3b3931 [feat](trx-rs): remove MARINE mode (superseded by virtual channels)
MARINE was a composite mode that ran both AIS and VDES decoders
simultaneously. It is now fully replaced by allocating two virtual
channels — one tuned to the AIS frequencies and one to VDES — each
decoded independently.

- trx-core/state: remove RigMode::MARINE variant
- trx-protocol/codec: remove MARINE parse/serialize
- trx-backend-ft817: remove MARINE from unsupported-mode guard
- trx-backend-ft450d: remove MARINE from FM CAT code mapping
- trx-backend-soapysdr: remove MARINE from bandwidth table, supported
  modes list, AIS channel activity check, parse_rig_mode, vchan_impl
  bandwidth table, demod selection, dsp/channel bandwidth / sample-rate
  / IQ-tap guards
- trx-server/audio: remove MARINE from AIS and VDES decoder activation
- trx-server/rig_task: remove MARINE from audio-streaming mode list
- trx-server/main: remove MARINE from bandwidth table, mode parser,
  VDES channel subscription match
- app.js: remove isMarineMode(), MARINE entry in MODE_BW_SPECS, MARINE
  bandwidth specs block in visibleBandwidthSpecs(), MARINE from
  decoder status mode lists, MARINE BW-edge drag guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 20:58:20 +01:00
sjg 60267d450b [feat](trx-rs): persistent multi-channel virtual channels with OOB eviction
Allow users to allocate multiple virtual channels independently of
browser tab count. Channels survive SDR center-frequency retuning as
long as they stay within the capture bandwidth; channels that fall
outside the SDR span are automatically destroyed.

Changes:
- trx-core: add AUDIO_MSG_VCHAN_DESTROYED (0x12) wire constant;
  add default subscribe_destroyed() to VirtualChannelManager trait
- trx-backend-soapysdr: update_center_hz() detects OOB channels,
  removes them, fires destroyed_tx broadcast; add destroyed_sender()
  and subscribe_destroyed() override
- trx-server/audio: recv_destroyed() helper avoids select! busy-loop
  for non-SDR backends; send AUDIO_MSG_VCHAN_DESTROYED to client when
  a channel is evicted server-side
- trx-client/audio_client: persist active_subs across TCP reconnects,
  re-subscribe on reconnect; handle AUDIO_MSG_VCHAN_DESTROYED by
  pruning vchan_audio map and forwarding UUID via vchan_destroyed_tx
- trx-frontend/lib: add vchan_destroyed broadcast field to
  FrontendRuntimeContext
- trx-client/main: wire vchan_destroyed_tx into audio client and
  frontend runtime context
- trx-frontend-http/vchan: remove per-session one-channel limit in
  allocate(); replace auto-evict in release_session_on_rig() with
  subscriber-count-only update; add remove_by_uuid() for server-
  triggered OOB destruction (skips redundant VChanAudioCmd::Remove)
- trx-frontend-http/server: spawn background task that forwards
  vchan_destroyed broadcast to ClientChannelManager.remove_by_uuid()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 20:50:49 +01:00
sjg 4e93dcc82a [fix](trx-frontend-http): fix virtual channel audio streaming
Three bugs prevented vchan audio from working reliably:

1. vchan.js: `vchanReconnectAudio` returned before updating
   `_audioChannelOverride` when audio was inactive. Switching to
   a virtual channel with audio off then starting audio manually
   would connect to the primary channel instead. Move the override
   update before the rxActive guard so it always reflects the
   active channel.

2. audio.rs: `audio_ws` returned 404 immediately if the channel
   was not yet in `vchan_audio`. The entry is populated when
   `AUDIO_MSG_VCHAN_ALLOCATED` arrives from the audio TCP client,
   which can lag the HTTP allocation by up to ~100 ms. Replace the
   instant 404 with a 2-second polling loop (50 ms intervals) so
   the WebSocket upgrade waits for the channel to be ready.

3. vchan.rs: `release_session_on_rig` evicted zero-subscriber
   channels silently — no `VChanAudioCmd::Remove` was sent.
   Collect evicted channel IDs before retain() and send Remove
   commands so the server-side DSP pipeline and Opus encoder are
   torn down properly on session disconnect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 20:24:48 +01:00
sjg 6131d7a1d6 [feat](trx-rs): per-virtual-channel audio streaming
Add end-to-end audio routing for virtual DSP channels:

Server (trx-server):
- New wire protocol: AUDIO_MSG_RX_FRAME_CH (0x0b), VCHAN_ALLOCATED (0x0c),
  VCHAN_SUB (0x0d), VCHAN_UNSUB (0x0e), VCHAN_FREQ (0x0f), VCHAN_MODE (0x10),
  VCHAN_REMOVE (0x11) frame types in trx-core audio.rs
- Add frame helpers: write_vchan_uuid_msg, write_vchan_audio_frame,
  parse_vchan_audio_frame, parse_vchan_uuid_msg
- Add ensure_channel_pcm() to VirtualChannelManager trait; implement in
  SdrVirtualChannelManager with create-or-subscribe semantics using client UUID
- Extend audio.rs handle_audio_client: VChanCmd dispatcher, per-channel Opus
  encoder tasks, VCHAN_SUB/UNSUB/FREQ/MODE/REMOVE reader loop handlers
- Thread vchan_manager through run_audio_listener / spawn_rig_audio_stack

Client (trx-client):
- Add VChanAudioCmd enum to trx-frontend; add vchan_audio and vchan_audio_cmd
  fields to FrontendRuntimeContext
- Extend audio_client: demux AUDIO_MSG_RX_FRAME_CH to per-channel broadcasters,
  handle VCHAN_ALLOCATED; forward VChanAudioCmd over TCP write loop
- Wire vchan_cmd_tx/rx channel in main.rs; pass vchan_audio map to audio_client
- ClientChannelManager.set_audio_cmd() / send_audio_cmd(): dispatch
  Subscribe/Remove/SetFreq/SetMode on allocate/delete/freq/mode operations
- Wire audio_cmd sender in server.rs serve() after creating vchan_mgr

HTTP frontend:
- /audio?channel_id=<uuid>: route WebSocket to per-channel Opus broadcaster
- vchan.js: vchanReconnectAudio() stops/restarts RX audio on channel switch;
  _audioChannelOverride in app.js selects primary vs virtual WS endpoint
- app.js: _audioChannelOverride variable; startRxAudio appends channel param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 08:06:18 +01:00
sjg 28036ab589 [feat](trx-frontend-http): sync mode picker to active virtual channel
- Add vchanSyncModeDisplay() in vchan.js; called from vchanSyncAccentUI()
  and vchanSubscribe() so the mode picker always reflects the active
  virtual channel's mode on switch and on channel-list refresh
- Guard the rig-state mode picker update in render() so it is skipped
  when vchanIsOnVirtual() is true, preventing primary-channel mode from
  overwriting the virtual channel selection

Note: per-channel audio and decoder output require server-side protocol
changes (separate Opus streams per virtual channel) and are not yet
implemented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 07:37:52 +01:00
sjg 3d284abab6 [fix](trx-frontend-http): center BW overlay on active virtual channel freq
When subscribed to a non-primary virtual channel the bandwidth overlay
was still anchored to lastFreqHz (channel 0).  Resolve the effective
center from the active vchan when vchanIsOnVirtual() is true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 07:30:24 +01:00
sjg 4bb7248257 [feat](trx-frontend-http): draw virtual channel markers on spectrum + OOB error
- Draw sky-blue dashed/solid lines on spectrum overlay for each vchan
- Active virtual channel gets a solid line; inactive ones are dashed
- Validate freq against SDR capture window in vchanSetChannelFreq and
  show a showHint error when tuning out of bandwidth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 07:27:51 +01:00
sjg af45c32222 [feat](trx-frontend-http): vchan freq display sync, BW accent, scheduler multi-channel
Virtual channel display:
- vchan.js: wrap refreshFreqDisplay() so the main freq field always shows
  the active virtual channel's frequency instead of channel 0's; expose
  vchanSyncAccentUI() to add vchan-ch-active CSS class (colored border) to
  #freq and #spectrum-bw-input when on a non-primary channel
- style.css: --vchan-color (#38bdf8 sky-blue), .vchan-ch-active box-shadow,
  vchan-picker active button left-border accent

Scheduler multi-channel slots:
- scheduler.rs: add center_hz (Option<u64>) and bookmark_ids (Vec<String>)
  to ScheduleEntry; SchedulerStatus gains last_center_hz and
  last_bookmark_ids; background task sends SetCenterFreq before SetFreq
  when center_hz is set and records extra bookmark_ids in status
- scheduler.js: center-freq input and extra-channel bookmark picker (tag
  list with + / × buttons) in the add-entry form; extra channels shown in
  the entries table
- index.html: center freq field + extra bookmark picker widgets; table
  gains Center freq and Extra channels columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 07:22:36 +01:00
sjg cef1741e40 [feat](trx-frontend-http): intercept freq/mode changes for virtual channels
When on a non-primary (virtual) channel, redirect freq and mode changes
to the channel metadata API instead of the server:

- vchan.js: add vchanIsOnVirtual(), vchanSetChannelFreq/Mode(); expose
  window.vchanInterceptMode() hook; wrap window.setRigFrequency so all
  callers (jog, freq input, bookmarks, spectrum click) are automatically
  redirected without modification
- app.js: check vchanInterceptMode() in applyModeFromPicker() before
  posting /set_mode
- bookmarks.js: check vchanInterceptMode() for mode in bmApply();
  setRigFrequency() redirect is automatic via the vchan.js wrapper;
  bandwidth and decoder toggles still apply regardless of channel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 07:08:36 +01:00
sjg 832e42ca3b [fix](trx-backend-soapysdr): suppress unused import/field warnings in vchan_impl
- VirtualSquelchConfig is only used in tests; gate import with #[cfg(test)]
- fixed_slot_count is reserved for future use; mark #[allow(dead_code)]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 07:01:37 +01:00
sjg e5aa74a1b6 [feat](trx-frontend-http): virtual channel manager and picker UI
Add client-side virtual channel support (Phase 1 — metadata only):

- vchan.rs: ClientChannelManager keyed by rig_id; tracks per-session
  channel subscriptions and broadcasts list changes via change_tx
- server.rs: instantiate Arc<ClientChannelManager> and expose as app_data
- api.rs: wire ClientChannelManager into /events SSE (session UUID,
  init_rig, update_primary, channel change stream, session cleanup on
  disconnect); add channel CRUD routes:
    GET/POST /channels/{rig_id}
    DELETE   /channels/{rig_id}/{channel_id}
    POST     /channels/{rig_id}/{channel_id}/subscribe
    PUT      /channels/{rig_id}/{channel_id}/freq|mode
- auth.rs: classify /channels/ prefix as Read access
- plugins/vchan.js: channel picker with +/× buttons, subscribe on click,
  SDR-only (shown when filter_controls capability is set)
- app.js: handle SSE `session` and `channels` events, call
  vchanApplyCapabilities from applyCapabilities
- index.html: #vchan-row div + <script src="/vchan.js">
- style.css: .vchan-picker pill styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 07:00:22 +01:00
sjg dda5ec17bb [feat](trx-backend): VirtualChannelManager trait + SdrVirtualChannelManager impl
Add VirtualChannelManager trait in trx-core::vchan with types VChannelInfo,
VChanError, and SharedVChanManager alias. Re-export from trx-backend::vchan.

Implement SdrVirtualChannelManager in trx-backend-soapysdr:
- Wraps Arc<SdrPipeline> + shared AtomicI64 center_hz
- add_channel / remove_channel / set_channel_freq / set_channel_mode
- Slot-stability: on remove, shifts pipeline_slot for surviving channels
- update_center_hz: recomputes IF offsets for all virtual channels on retune
- update_primary_meta: keeps channel-0 freq/mode in sync for API consumers

Wire into SoapySdrRig (holds Arc<SdrVirtualChannelManager>, exposes
channel_manager()), SdrPipeline (shared_center_hz AtomicI64), and RigHandle
(vchan_manager: Option<SharedVChanManager>). main.rs extracts the manager
before boxing the SDR rig and stores it in the handle.

Add max_virtual_channels to SdrConfig (default 4, TOML-configurable).
Add 5 unit tests: add, remove, permanent guard, cap, out-of-bandwidth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 00:48:31 +01:00
sjg 05169912b1 [feat](trx-backend-soapysdr): dynamic virtual channel DSP slots
Replace the fixed channel_dsps Vec (thread-owned) with an
Arc<RwLock<Vec<...>>> shared between the IQ read thread and the async
side, enabling live add/remove of virtual DSP channels.

Cache audio construction params in SdrPipeline so add_virtual_channel()
can build ChannelDsp instances without being re-passed them. Add:
  - SdrPipeline::add_virtual_channel() / remove_virtual_channel()
  - SoapySdrRig::virtual_channel_add/remove/set_freq/set_mode()
  - SoapySdrRig::center_hz() / half_span_hz() accessors

The IQ read loop holds a brief read lock (~2 ms per block) while
processing all channels; write lock for add/remove waits at most one
block. All 27 existing tests continue to pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 00:26:23 +01:00
sjg 2f115fbec3 [docs](trx-rs): add virtual channels design document
Describes the per-rig dynamic virtual channel architecture for SDR rigs:
session binding via SSE session_id, channel lifecycle (ref-counted,
auto-freed on last subscriber disconnect), center-freq conflict rules,
per-channel audio WebSocket and decode SSE, frontend picker UI, and
phased implementation plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-11 00:17:52 +01:00
sjg d53d60629e [feat](trx-frontend-http): treat 00:00–00:00 time span as all-day entry
start == end previously matched nothing (empty range).  Now treated as a
24-hour window, making it easy to define a catch-all bookmark without
manually entering 00:00–23:59.

UI shows "All day / —" in the entries table and tooltip hints on both time
inputs explain the convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:43:53 +01:00
sjg e4cfd35282 [feat](trx-frontend-http): per-entry interleave time in TimeSpan scheduler
Each ScheduleEntry can now carry its own interleave_min, overriding the
config-level default for that slot in the cycle.  The cycle length is the
sum of all active entries' effective durations (weighted), so entries with
longer individual interleave times occupy proportionally more time.

UI: "Interleave (min, optional)" input in the add-entry form; value shown
in the entries table (displays "—" when using the config default).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:35:52 +01:00
sjg 998c6ad0e6 [style](trx-frontend-http): align scheduler buttons with site-wide button styles
Remove custom padding, border-radius, color, cursor, and hover rules from
.sch-save-btn, .sch-reset-btn, and .sch-remove-btn — the global button rule
already handles all of that consistently across every theme.

.sch-save-btn retains only the accent-green background/border-color to mark
it as the primary action; the global hover/active/disabled transitions still
apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:32:16 +01:00
sjg f1d412a566 [style](trx-frontend-http): fix clippy::manual_range_contains in scheduler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:28:20 +01:00
sjg 877573c905 [fix](trx-frontend-http): populate scheduler bookmark picker after async load
The TimeSpan bookmark <select> was populated in wireSchedulerEvents() which
runs before the apiGetBookmarks() fetch completes, leaving it empty.
Moved population to populateTsBookmarkSelect() called from loadScheduler()'s
.then() callback so bookmarkList is already filled.

Also pre-fill grayline lat/lon from serverLat/serverLon when the field has
no saved value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:28:00 +01:00
sjg 4f9f93c9c1 [feat](trx-frontend-http): add interleave time for overlapping TimeSpan entries
When multiple time-span entries are active simultaneously, the scheduler
now cycles through them by slot: slot = floor(utc_min / interleave_min) % count.
The interleave_min field is optional (null disables, first match wins).

UI: "Interleave time (min)" number input in the TimeSpan section with a
hint explaining the behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:25:12 +01:00
sjg c9d204ad1c [fix](trx-frontend-http): populate scheduler rig list after SSE delivers rig data
initScheduler() runs before the first SSE event, so lastRigIds is empty.
Now applyRigList() calls reloadSchedulerRigSelect() whenever the rig list
updates, and renderSchedulerRigSelect() loads the config for the first rig
if currentRigId was previously unset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:22:49 +01:00
sjg 6874055b1c [feat](trx-frontend-http): add Background Decoding Scheduler
Implements a scheduler that retunes the rig automatically when no SSE
clients are connected.  Two modes are supported:

- Grayline: tunes to per-period bookmarks (dawn/day/dusk/night) based on
  an inline NOAA solar algorithm given station lat/lon.
- Time Span: tunes to bookmarks within user-defined UTC windows; midnight-
  spanning intervals supported.

Backend:
- SchedulerStore (PickleDB, sch:{rig_id} keys) in scheduler.rs
- spawn_scheduler_task polls every 30 s, checks context.sse_clients == 0,
  sends SetFreq + SetMode via RigRequest with rig_id_override
- HTTP API: GET/PUT/DELETE /scheduler/{rig_id}, GET …/status
- sse_clients Arc<AtomicUsize> added to FrontendRuntimeContext and shared
  with the SSE counter in build_server (single source of truth)
- /scheduler/ added to Read auth routes (write requires Control)

Frontend:
- Scheduler tab (clock icon, 6th position) with Grayline/TimeSpan UI
- scheduler.js plugin: loads config + bookmarks, live status polling
  every 15 s, write controls hidden for Rx-role users
- CSS .sch-* component styles added to style.css
- SCHEDULER.md design document at repo root

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 23:20:42 +01:00
sjg 46c0f8d0bb [fix](trx-frontend-http): deduplicate AIS history snapshot by MMSI
AIS vessels transmit every 2-30 s; without deduplication the 24-hour ring
buffer can hold tens of thousands of entries, making the /decode/history
response huge and causing O(n^2) DOM thrashing on the client side.

- Add AIS_HISTORY_MAX = 10 000 to cap the ring buffer memory footprint.
- snapshot_ais_history() now returns the latest message per MMSI (one entry
  per vessel), sorted ascending by ts_ms so the client replays in order.

This matches APRS history behaviour: APRS stations transmit infrequently so
their history is naturally compact; AIS history is now equally compact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 20:17:01 +01:00
sjg e1fe1980ea [fix](trx-frontend-http): fix decode history invisible on first load
Two root causes:

1. /decode/history was classified as Control in the auth router (not listed
   in Read routes), so it returned 401/403 when auth is enabled and the
   user had no session or rx-only role. Add it to the Read route list.

2. connectDecode() was called from window.load unconditionally, before the
   auth flow completed. On first load with auth enabled the session cookie
   doesn't exist yet, so the history fetch fails silently. Move the call to
   be alongside connect() in initializeApp(), login, and guest handlers so
   it always runs with valid auth context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:52:24 +01:00
sjg d862d953e1 [fix](trx-frontend-http): flush live decode buffer only after history drain completes
drainDecodeHistory() chunks work via setTimeout but flushLiveBuffer() was
called synchronously right after starting the drain, so live messages could
interleave with in-progress history chunks. Pass flushLiveBuffer as an
onDone callback so live messages are only dispatched once all history chunks
have been processed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:40:08 +01:00
sjg 81515d921e [fix](trx-frontend-http): AIS map markers use scheme lead color (--accent-green)
Use --accent-green (the primary/lead accent color) for AIS vessel markers and
tracks instead of a hardcoded or red-based color, so they match the active
buttons and other prominent UI elements for every color scheme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:37:15 +01:00
sjg 6a755ef2b4 [fix](trx-frontend-http): use dedicated --ais-accent color for map markers
Replace --accent-red with a new --ais-accent CSS variable (default #00aacc
cyan-blue) so AIS vessel markers and track lines are visually distinct from
other UI elements regardless of theme. Light theme uses a slightly darker
#0088aa for readability on the map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:34:19 +01:00
sjg a62997d6ce [fix](trx-frontend-http): propagate color/outline in TrackSymbol setAisState
setAisState only updated heading/course/speed, silently dropping color and
outline fields. Extend it to also accept and apply those fields so theme
color refreshes take effect without recreating the marker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:32:14 +01:00
sjg 4740b38ad4 [fix](trx-frontend-http): AIS map markers and tracks follow theme accent color
Read --accent-red CSS variable at draw time so markers, track lines, and
TrackSymbol icons automatically match the active color scheme. Add
refreshAisMarkerColors() called on theme toggle and style picker changes
to repaint existing markers without a page reload. Also buffer live SSE
decode messages until the /decode/history fetch settles to eliminate the
history-appears-after-reload race condition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:24:34 +01:00
sjg ce1ca48384 [fix](trx-frontend-http): remove animation from spectrum center-shift arrows
The global button transition and the :active scale(0.97) transform were
interfering with the translateY(-50%) centering, making the buttons jump
on press. Added transition:none and reduced :active to translateY(-50%)
only (no scale).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:07:05 +01:00
sjg 00fc1dbdfb [fix](trx-frontend-http): increase map height in normal view
Raise the viewport cap from 60% to 75% of window height and relax the
aspect-ratio divisor from 1.9 to 1.55, giving the map more vertical
space without requiring fullscreen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:03:49 +01:00
sjg 9a398f3754 [fix](trx-frontend-http): map fullscreen fallback for mobile Safari
Mobile Safari (iOS) blocks requestFullscreen() on non-video elements,
so the Fullscreen button silently did nothing.

Add a CSS-based fake fullscreen path:
- mapEnterFakeFullscreen() adds .map-fake-fullscreen to #map-stage
  (position:fixed; inset:0; z-index:9000; height:100dvh) and
  map-fake-fullscreen-active to <body> (overflow:hidden).
- toggleMapFullscreen() tries native fullscreen first; catches the
  thrown NotAllowedError (or any other error) and falls back to the
  CSS path. Also handles the case where requestFullscreen is absent.
- mapIsFullscreen() checks for the CSS class in addition to the
  native fullscreen element references.
- mapExitFakeFullscreen() removes both classes on exit.
- Escape key exits CSS fake fullscreen (native handles its own Escape).
- sizeAprsMapToViewport() uses window.innerHeight for the fake path
  since clientHeight may not reflect fixed layout synchronously.
- sizeAprsMapToViewport() is called via requestAnimationFrame after
  toggling so layout is settled before the Leaflet invalidateSize().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 19:01:48 +01:00
sjg 2e8f025b19 [fix](trx-frontend-http): fix spectrum center-shift arrow behavior
Two bugs fixed:

1. Wrong vertical position of shift buttons and bookmark side panels.
   top: calc((--spectrum-plot-height - --overview-plot-height) / 2)
   evaluates to 0 when both vars are equal (default 160 px), so
   translateY(-50%) placed the buttons at the top edge of .spectrum-wrap
   instead of mid-canvas. Changed to calc(--spectrum-plot-height / 2).

2. Rapid clicks on the arrows did not accumulate: each call read
   lastSpectrumData.center_hz which is only updated when the server
   sends a new spectrum frame. Added spectrumCenterPendingHz to track
   the optimistic center immediately on click; reset when the server
   confirms a frame near the expected position.

Also hide .spectrum-bookmark-side on ≤640px (no horizontal room on
narrow phones); previously visible but clipped off-screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 18:57:59 +01:00
sjg 51df676e46 [feat](trx-frontend-http): mobile UI improvements
- Fix 5-tab bottom nav (grid was repeat(4) with 5 tabs; About overflowed)
- Add SVG icons to each tab; show icon+label on mobile bottom nav
- Swipe left/right to switch tabs (excludes jog wheel, spectrum canvas,
  map, scrollable containers and form inputs to avoid conflicts)
- Extract navigateToTab() helper used by both click and swipe handlers
- Collapse header subtitles at ≤640px to reclaim vertical space
- Bookmark table → 2-column card layout at ≤640px with ::before labels
- Audio volume labels switch to horizontal row layout at ≤520px;
  squelch slider now also spans full width
- Controls tray uses overflow-x: auto (not visible) at ≤760px so
  content wider than viewport scrolls rather than overflowing layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 18:46:44 +01:00
sjg 35983eb971 [feat](trx-frontend-http): split decode history into separate HTTP endpoint
Move history replay out of the /decode SSE stream into a new
GET /decode/history JSON endpoint. The JS client now opens /decode
immediately for live packets (no gating) and fetches history in
parallel via fetch(), draining it in the background with the existing
chunked drainDecodeHistory() helper.

This ensures real-time decode messages are never blocked by a large
history payload, and removes the historyReceived gate entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 18:33:40 +01:00
sjg 5a3c013db5 [style](trx-frontend-http): system font, button states, scrollbars, phosphor theme
System font stack: replace bare 'sans-serif' with system-ui / -apple-system
/ BlinkMacSystemFont / Segoe UI chain — sharper rendering on all platforms
at zero extra load cost.

Button hover/active: add transition (100ms) + color-mix hover brightening
+ active depression (translateY 1px) to all buttons. Previously buttons had
zero visual feedback on interaction.

Scrollbar styling: thin (6px) custom scrollbars via ::-webkit-scrollbar and
scrollbar-width/color for Firefox. Thumb uses border-color tinted with the
accent on hover — matches each theme automatically via CSS variables.

Phosphor theme: classic green-phosphor CRT aesthetic — near-black background,
#39ff14 neon-green accent, glow text-shadow on the freq display, matching
spectrum/waterfall canvas palette. Both dark and light variants included.
Registered in the style picker select, setStyle() valid list, and
CANVAS_PALETTE.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 18:23:15 +01:00
sjg 03a5bff4cc [fix](trx-frontend-http): send decode history as single JSON array event
Previously the server emitted N individual SSE events (one per decoded
message) followed by a history_done sentinel. With a 1.3 MB history
this caused thousands of EventSource onmessage callbacks each blocking
the JS main thread, interrupting audio playback and spectrum rendering
for 50+ seconds.

Server: serialize the entire history Vec as a single named "history"
event containing a JSON array, then chain directly into the live
decode stream.  One serde_json::to_string call instead of N.

JS: listen for the "history" event, parse the array once, pass it to
the existing drainDecodeHistory() chunked dispatcher (30 msgs per
setTimeout slice to stay off the main thread), then gate onmessage
dispatching on historyReceived.  Removes the historyBuffer accumulator
and the history_done event entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-10 18:12:27 +01:00
sjg e29b7ed3d3 [fix](trx-frontend-http): reduce INP on freq input from ~1500ms to <50ms
Chrome classifies wheel events as "pointer" interactions and tracks async
continuations initiated by the handler. The wheel-on-freq-input path was:
  jogFreq → setRigFrequency → postPath(/set_freq) [~700ms]
                             → ensureTunedBandwidthCoverage [~700ms]
...two sequential network round-trips totalling ~1400ms of INP.

Three changes:

1. yieldToMain(): add a scheduler.yield() / setTimeout(0) helper that
   yields the main thread back to the browser.  Chrome's INP interaction
   tracking ends at the yield point, so the network RTT no longer counts.

2. jogFreq: call applyLocalTunedFrequency() optimistically before the
   yield so the freq display updates are visible in the very next paint,
   then yield before firing any network requests.

3. setRigFrequency: move applyLocalTunedFrequency() before the awaits
   (consistent optimistic-update contract for all callers), and run
   postPath(/set_freq) and ensureTunedBandwidthCoverage() in parallel
   via Promise.all — they are independent server operations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-09 23:10:18 +01:00
sjg 003075c5e4 [style](trx-frontend-http): use div_ceil() per clippy suggestion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-09 23:02:50 +01:00
sjg 541e27bb7a [arch](trx-client): watch channel for spectrum + dedicated TCP connection
Replace Arc<Mutex<SharedSpectrum>> with Arc<watch::Sender<SharedSpectrum>>
throughout the stack:

- SharedSpectrum: remove revision counter, derive Clone, make fields pub,
  rename replace() → set(). The watch channel handles dedup natively.
- FrontendRuntimeContext.spectrum: Mutex → watch::Sender; SSE clients
  call .subscribe() to get a push-based receiver at zero polling cost.
- RemoteClientConfig: derive Clone, switch spectrum field to match.

Spectrum polling moves to a dedicated TCP connection (run_spectrum_connection
+ handle_spectrum_connection spawned as a separate tokio task). This
eliminates head-of-line blocking: spectrum timeouts no longer stall state
polls or user commands on the main connection. Each side reconnects
independently; the spectrum task marks the frame None while reconnecting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-09 22:59:50 +01:00
sjg aa079598bd [refactor](trx-frontend-http): replace /spectrum poll+mutex with WatchStream
Subscribe each SSE client to the watch::Sender<SharedSpectrum> rather
than running an IntervalStream that locks a Mutex every 40 ms. Clients
are now woken push-style exactly when new spectrum data arrives; the
revision counter is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-09 22:58:39 +01:00
sjg 9a96ed8236 [perf](trx-client): three quick-win optimizations
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>
2026-03-09 22:48:17 +01:00
sjg de11c7ff51 [perf](trx-client): TCP_NODELAY, Arc spectrum bins, eliminate format allocs
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>
2026-03-09 22:26:24 +01:00
sjg 2030d0b050 Merge pull request #42 from sgrams/fix/events-source-latency
[fix](trx-client): reduce events source latency to <1 s
2026-03-09 22:15:19 +01:00
sjg 8cb6292c1d [fix](trx-client): reduce events source latency to <1 s
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>
2026-03-09 22:13:53 +01:00
sjg 824434e27e [fix](trx-frontend-http): avoid double JSON parse/serialize in /events
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>
2026-03-09 21:46:37 +01:00
sjg 75355c75a5 [feat](trx-frontend-http): encode spectrum bins as compact i8/base64 SSE
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>
2026-03-09 21:34:11 +01:00
sjg 1d8b77ae44 [fix](trx-server): remove unused AsyncWriteExt import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-09 21:24:25 +01:00
sjg 409b173f62 [feat](trx-server): gzip-compress history replay blob
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>
2026-03-09 21:17:49 +01:00
sjg 26fbd37b6d [feat](trx-frontend-http): wire HF APRS toggle button and bookmark decoder
- 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>
2026-03-08 20:43:55 +01:00
sjg ac586418fc [fix](trx-rs): add AUDIO_MSG_HF_APRS_DECODE wire type for HF APRS
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>
2026-03-08 20:26:23 +01:00
sjg 19d6d2e50b [feat](trx-rs): add HF APRS decoder (300 baud, 1600/1800 Hz AFSK)
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>
2026-03-08 20:17:37 +01:00
sjg ee821a71b1 [fix](trx-frontend-http): avoid per-draw GPU realloc in WebGL renderer
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>
2026-03-08 18:51:34 +01:00
sjg 250300395f [fix](trx-server): batch history replay into single TCP write
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>
2026-03-08 18:39:50 +01:00
sjg 6c4853c88c [fix](trx-frontend-http): remove per-packet console.log from APRS handler
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>
2026-03-08 18:37:55 +01:00
sjg f8d7472510 [fix](trx-vdes): fix VDES warm-up period
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-08 08:44:41 +01:00
sjg b4fb42dba4 [fix](trx-server): clear CW history on ResetCwDecoder
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>
2026-03-07 10:17:39 +01:00
sjg cbf3301664 [fix](trx-server): remove unused clear_cw_history method
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>
2026-03-07 10:16:36 +01:00
sjg 53199642ea [fix](trx-server): fix VDES channel routing and IQ sample rate
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>
2026-03-07 10:14:09 +01:00
sjg a1bcf6f8bf [fix](trx-vdes): improve symbol timing and add burst CFO correction
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>
2026-03-07 10:14:00 +01:00
sjg 0fc92bb1de [fix](trx-client): remove stale history_store.rs scaffold
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>
2026-03-07 10:13:49 +01:00
sjg 2f1b0609fb [feat](trx-server): persist 24h decode history across restarts
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>
2026-03-07 09:50:42 +01:00
sjg b65b85e13c [feat](trx-client): persist 24h decode history across restarts
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>
2026-03-07 09:43:48 +01:00
sjg 1001786a08 [fix](trx-backend-soapysdr): ensure IQ stream recovery after overflow
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>
2026-03-07 09:31:17 +01:00
sjg bfc510e1eb [fix](trx-client,trx-server): fix spectrum death and stuck shutdown after IQ overflow
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>
2026-03-07 09:25:08 +01:00
sjg e2c568a98a [fix](trx-frontend-http): async decode history replay to unblock UI
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>
2026-03-07 09:12:13 +01:00
sjg b23f05966f [fix](trx-frontend-http): show CW bar line as it decodes, not after flush
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>
2026-03-06 22:33:02 +01:00
sjg 90d446c8f4 [fix](trx-frontend-http): add cw-bar-overlay to bar overlay CSS selector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 22:31:22 +01:00
sjg 4272094882 [feat](trx-frontend-http): add CW live bar overlay
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>
2026-03-06 22:28:41 +01:00
sjg 175abe35f9 [fix](trx-frontend-http): guard CW auto UI against concurrent SSE updates
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>
2026-03-06 22:07:05 +01:00
sjg 91562b2a4b [fix](trx-cw): fix window size, dot/dash threshold, and cleanup
- 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>
2026-03-06 21:30:58 +01:00
sjg 4d22fa964d [feat](trx-frontend-http): show AIS tracks only for selection
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 17:29:12 +01:00
sjg edaef98522 [feat](trx-frontend-http): show APRS tracks only for selection
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 17:11:04 +01:00
sjg 7de847b504 [fix](trx-frontend-http): preserve APRS event timestamps
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 16:19:46 +01:00
sjg faaaad6ab9 [fix](trx-frontend-http): defer decode SSE until plugins load
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 16:17:28 +01:00
sjg 69b2aee753 [fix](trx-server): preserve decoder history timestamps
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 16:08:34 +01:00
sjg e3378d5f49 [fix](trx-client): start decode collector before audio replay
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 16:01:31 +01:00
sjg f9c0fa6981 [fix](trx-server): add rig task timeouts to avoid stalls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 15:45:10 +01:00
sjg ab27f111d5 [fix](trx-client): reconnect on remote poll timeouts
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 15:33:28 +01:00
sjg 8c16f0da41 [fix](trx-frontend-http): preserve decoder history timestamps
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 11:58:07 +01:00
sjg 18d3a00c07 [fix](trx-server): optimize replay and stamp APRS history time
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 11:58:02 +01:00
sjg e5b9fb7aca [refactor](trx-core): add buffered audio write helper
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-06 11:57:55 +01:00
sjg 3d0cdbd181 [docs](trx-rs): document SoapySDR squelch settings
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:43:32 +01:00
sjg 890abcdc24 [feat](trx-frontend-http): add SQL slider and spectrum screenshot hotkey
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:43:28 +01:00
sjg 8bd779a239 [feat](trx-server): wire SDR squelch through server config and rig task
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:43:23 +01:00
sjg 3eb5a615b9 [feat](trx-backend-soapysdr): extend squelch and boost high denoise
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:43:19 +01:00
sjg 3188c9b0ad [feat](trx-protocol): add SDR squelch command mapping
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:43:13 +01:00
sjg 5597a8c206 [feat](trx-core): add SDR squelch command and state fields
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:43:09 +01:00
sjg 3d9003feb2 [feat](trx-server): add per-rig enable switch in multi-rig config
Add [[rigs]].enable (default true) and skip disabled rigs during\nresolution/uniqueness checks.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:15:45 +01:00
sjg 3f2892e245 [fix](trx-frontend-http): match waterfall BW resize behavior to waveform
Enable BW edge drag initiation on overview/waterfall canvas and align drag/click suppression behavior with waveform interactions.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 22:10:49 +01:00
sjg 5dcc117e61 [fix](trx-backend-soapysdr): disable hidden AIS DSP outside AIS modes
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>
2026-03-05 22:01:21 +01:00
sjg 9ed93ec0fa [feat](trx-frontend-http): align waterfall click and restore bookmark marker lines
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>
2026-03-05 21:56:19 +01:00
sjg 8be3b273bc [fix](trx-backend-soapysdr): reduce sdr-iq-read CPU spin on idle reads
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>
2026-03-05 21:50:17 +01:00
sjg 4e6a800391 [fix](trx-frontend-http): fix waterfall updates and bookmark top anchoring
Ensure overview waterfall incremental updates continue on HiDPI and anchor bookmark chips to the top of the full spectrum view (waterfall + waveform).\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 21:45:49 +01:00
sjg c4f0844137 [fix](trx-frontend-http): optimize WebGL rendering and pin bookmarks to top
Improve WebGL runtime performance by caching/downsampling overview waterfall texture updates and batching marker/dashed-line draws; keep bookmark chips anchored at the top of the waterfall area.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 21:41:49 +01:00
sjg 27b90a62c5 [feat](trx-frontend-http): migrate frontend canvas rendering to WebGL
Replace Canvas2D rendering in spectrum, overview, signal overlay, and CW tone picker with a shared WebGL renderer and wire the new asset into frontend HTTP routes.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 21:37:50 +01:00
sjg 633ad92dd6 [fix](trx-frontend-http): decouple signal view from spectrum and fix canvas scaling 2026-03-05 21:14:35 +01:00
sjg a041dd4505 [fix](trx-backend-ft817): bound lock/unlock reads to prevent set_mode timeout 2026-03-05 20:44:32 +01:00
sjg ccef359034 [fix](trx): speed up web load and harden FT817/Soapy recovery 2026-03-05 20:36:46 +01:00
sjg bc0d9a6273 [fix](ftx): treat second callsign as source in directed traffic 2026-03-05 20:23:22 +01:00
sjg bf74044c05 [fix](trx): normalize FT8 history freq and add map locator links 2026-03-05 19:48:20 +01:00
sjg 8e8d21b527 [feat](trx-frontend): add longer peak hold durations 2026-03-05 19:34:17 +01:00
sjg 1370234b35 [style](trx-frontend): make signal split slider narrower 2026-03-05 19:33:29 +01:00
sjg 49f44e84a0 [fix](trx): ignore FTx farewell pseudo-locators and narrow split slider 2026-03-05 19:24:54 +01:00
sjg 05e72e21e8 [feat](trx-frontend): reset signal split on slider double-click 2026-03-05 18:34:59 +01:00
sjg 2e6b9f87fb [fix](trx-frontend): compact split slider and center side controls 2026-03-05 18:28:42 +01:00
sjg 083d009aa5 [feat](trx-frontend): add signal split slider and align spectrum side controls
Add a right-side slider to control the waterfall/waveform split and\npersist the selected ratio locally.\n\nRework spectrum height layout so manual resize adjusts total plot height\nwhile split controls the overview/spectrum ratio.\n\nKeep center-frequency arrows and side bookmark stacks vertically centered\nwithin the full spectrum view container.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 18:17:34 +01:00
sjg 4c69e2bcde [fix](trx-frontend): improve spectrum resize interaction
Make the spectrum resize grip easier to use and style it closer to\nexisting controls.\n\nKeep auto-max behavior by default while allowing manual drag to\nresize beyond viewport fill, enforcing only the minimum height.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 18:08:16 +01:00
sjg a905fa9678 [fix](trx-frontend): maximize spectrum viewport and add resize grip
Make spectrum plot use maximum available viewport height by default.
Add draggable vertical resize grip with a reasonable minimum height
and double-click reset back to auto-max.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-05 17:45:50 +01:00
sjg bfff095cf0 [fix](trx-core): auto-enable CW decode on CW/CWR mode
When mode is set to CW/CWR, force cw_decode_enabled=true in state
application to avoid stale disabled decode state.
Also route initial mode setup through apply_mode.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 23:17:44 +01:00
sjg 14d58ce989 [fix](trx-server): hard-gate CW decode to CW/CWR modes
Enforce CW decode processing only when mode is CW/CWR and decoder
is enabled, even within the receive loop, and remove CW path RMS
zero-gating to preserve weak tone decoding.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 23:14:23 +01:00
sjg 75e177a5f4 [fix](trx-core): enable CW decoder by default
Set cw_decode_enabled default to true so CW decoding can start
without an unavailable UI toggle.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 23:14:14 +01:00
sjg 9786030f7d [fix](trx-cw): stabilize auto tone scan window
Keep manual CW tone setting range at 100-10000 Hz, but limit auto-tone
scanning to 300-1200 Hz to avoid locking onto high-frequency noise.
Retain Nyquist-safe upper clamping.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 23:09:07 +01:00
sjg c1f37a320f [fix](trx-cw): widen tone scan range and clamp by Nyquist
Expand CW decoder tone scan/control range to 100-10000 Hz and cap the
upper bound by sample-rate Nyquist to keep detection stable.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 23:05:10 +01:00
sjg f3f0b1dfd3 [fix](trx-server): accept CW tone range up to 10 kHz
Expand server-side CW tone command clamping to 100-10000 Hz to match
frontend controls and picker behavior.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 23:05:03 +01:00
sjg d8f7ebafa0 [fix](trx-frontend): fix CW picker visibility and widen tone range
Fix CW picker redraw when the decoder sub-tab becomes visible to avoid
white/blank canvas rendering.
Widen CW tone picker/input range to 100-10000 Hz and raise CW/CWR
bandwidth max to 9 kHz.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 23:04:56 +01:00
sjg 878b50cad7 [fix](trx-server): gate near-silent audio before decode
Apply an RMS gate to near-silent decoder input frames and zero them
before APRS/CW/FT8/WSPR processing to avoid false decodes from
unsquelched/no-signal noise.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:58:02 +01:00
sjg 239857d536 [fix](trx-frontend): restore CW picker rendering across spectrum edges
Remove CW tone-window clipping to current spectrum edge coverage so
the picker always renders the audio range and does not blank out.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:53:31 +01:00
sjg 4f01db0801 [fix](trx-frontend): render CW picker as audio spectrum trace
Render the CW tone picker as an audio-frequency spectrum trace with grid,
line and filled area instead of a normalized gradient wash.
Keep exact click-to-tone selection and marker behavior.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:49:52 +01:00
sjg 1ebe65e0f2 [fix](trx-frontend): fix CW audio picker and bookmark add button
Use an audio-window tone picker for CW with exact click-to-tone mapping.
Make + Add Bookmark inherit the shared button style.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:46:08 +01:00
sjg 2a9cd5ed0e [fix](trx-frontend): add decoder pause controls and rename tab
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:37:26 +01:00
sjg eac5e142db [fix](trx-frontend): rework cw auto and tone picker behavior
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:31:08 +01:00
sjg d6ad873bfe [fix](trx-frontend): retune center after bw edge drag
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:25:39 +01:00
sjg 0175b1c916 [feat](trx-frontend): add map search filter
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 22:17:55 +01:00
sjg a45979fe9b [fix](trx-frontend): default map sources to hide bookmarks
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 20:04:39 +01:00
sjg e91d36775b [fix](trx-frontend): align band chip colors with locator overlays
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 20:00:33 +01:00
sjg 4b9e84e194 [fix](trx-frontend): refine map locator styling
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:55:37 +01:00
sjg 013667f284 [fix](trx-frontend): align bw resize with one-sided filters
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:49:44 +01:00
sjg 0836091bbc [fix](trx-client): preserve ft8 history on reload
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:45:17 +01:00
sjg 8b174db04d [feat](trx-frontend): add map fullscreen toggle
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:37:38 +01:00
sjg 125237f53d [fix](trx-frontend): tighten map sizing and path theming
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:32:01 +01:00
sjg fbfff4154f [fix](trx-frontend): compact map tooltip frequencies
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:26:48 +01:00
sjg c34ecc493d [fix](trx-frontend): refine map source and popup states
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:22:32 +01:00
sjg 9e1bdf2435 [fix](trx-frontend): refine map popup rendering
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:17:36 +01:00
sjg 75a74d4063 [fix](trx-frontend): render one-sided bw for ssb modes
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:12:32 +01:00
sjg 283125989b [fix](trx-frontend): animate selected locator paths
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 19:02:31 +01:00
sjg d1968bd2ba [fix](trx-frontend): tighten bookmark and map filter overflow
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-04 18:55:26 +01:00
sjg 046d0a33af [fix](trx-frontend): gate cw status by rig mode
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:41:39 +01:00
sjg bd5c909695 [fix](trx-frontend): polish map filter layout
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:37:05 +01:00
sjg a194043caf [fix](trx-frontend): unify map source filters
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:31:44 +01:00
sjg 52bf573e4e [fix](trx-frontend): snap locator bands to nearest ham band
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:24:21 +01:00
sjg efe9dc346b [fix](trx-frontend): make locator filters two-phase
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:20:38 +01:00
sjg df173a3202 [fix](trx-frontend): make locator details click-only
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:17:34 +01:00
sjg 4bdb4937c9 [fix](trx-frontend): replace locator wavelength filters with bands
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:12:27 +01:00
sjg b9b420fa54 [fix](trx-frontend): add locator source and wavelength filters
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 22:01:32 +01:00
sjg 80de405a03 [fix](trx-frontend): refine locator map controls and tooltips
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 21:53:59 +01:00
sjg 68b6bfa0b7 [fix](trx-frontend): improve bookmark dialog and map tooltips
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 20:29:45 +01:00
sjg 2eb3c4f66f [fix](trx-vdes): normalize geo boxes for map decoding
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 20:10:02 +01:00
sjg 295a395999 [fix](trx-frontend): balance waveform and waterfall heights
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:49:39 +01:00
sjg e3a5fee666 [style](trx-backend-soapysdr): format WFM code
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:41:30 +01:00
sjg 087ebb33bb [style](trx-backend-ft817): format mode setup
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:41:20 +01:00
sjg 8042624c39 [style](trx-server): format audio and startup code
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:41:17 +01:00
sjg 417a86284c [style](trx-core): format rig trait signatures
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:41:14 +01:00
sjg da9d762ad2 [style](trx-frontend): format http audio code
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:41:06 +01:00
sjg d72acf9e90 [style](trx-client): format wrapped lines
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:41:03 +01:00
sjg d40aa7e614 [style](trx-vdes): format decoder source
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:40:58 +01:00
sjg 5cdcdd3498 [feat](trx-frontend): add bookmark locators for distance tooltips
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 19:39:53 +01:00
sjg 1825a0a003 [fix](trx-server): honor CW decode enable flag
Gate the CW decoder task on cw_decode_enabled in both startup and state changes.
This restores the UI toggle and prevents decode activity when CW decode is off.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:33:33 +01:00
sjg 004ac19ea6 [fix](trx-cw): emit CW transition events
Restore empty events on tone edges so the frontend signal indicator updates again.
Add synthetic-tone tests covering transitions and basic decoding.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:33:27 +01:00
sjg 6f57813d36 [fix](trx-frontend-http): fix cw.js picker
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:27:40 +01:00
sjg 9bf579aaa0 [fix](trx-frontend): correct CW tone picker behavior
Align the CW tone picker to the actual tone window and stop live CW decode events from overriding manual tone selection.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:23:28 +01:00
sjg fa91cf011b [fix](trx-frontend): correct CW auto toggle requests
Send boolean values for the CW auto toggle query and improve the CW tone picker waterfall contrast.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:19:30 +01:00
sjg e8002a10ab [fix](trx-frontend): expose spectrum state to CW picker
Mirror live spectrum, tuned frequency, and bandwidth state onto the window object so the CW tone picker can render from current spectrum data.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:16:07 +01:00
sjg acecaff810 [fix](trx-backend-ft817): reject unsupported modes
Make FT-817 mode setting fail for unsupported modes instead of silently mapping SDR-only modes onto FM.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:12:49 +01:00
sjg 564f25fc6d [fix](trx-backend-ft817): hide unsupported decode modes
Remove SDR-only decoder modes from the FT-817 supported mode list so they are not shown in that rig's mode picker.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:10:52 +01:00
sjg 953edb336a [feat](trx-frontend): add CW tone waterfall picker
Add a mini waterfall-based CW tone selector in the plugin tab and make CW auto mode apply only to WPM.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:06:28 +01:00
sjg 7188d38610 [fix](trx-backend-soapysdr): boost AM audio further
Raise the AM audio AGC target and headroom again so the restored envelope demod path plays at a stronger level.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 02:02:15 +01:00
sjg 605531184b [fix](trx-backend-soapysdr): raise AM audio gain
Retune the AM audio AGC target, timing, and headroom so the restored envelope demod path plays back at a stronger level.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:59:50 +01:00
sjg c1ed936c2e [fix](trx-backend-soapysdr): restore simple AM demodulation
Revert the recent AM coherent detector experiment and restore the original envelope detector path.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:58:11 +01:00
sjg e2e4d34f30 [fix](trx-backend-soapysdr): reduce AM demod distortion
Remove half-wave clipping from the AM coherent detector output and slow the carrier-reference tracking to reduce audible distortion.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:55:30 +01:00
sjg 298b7247f8 [feat](trx-backend-soapysdr): improve AM coherent demodulation
Replace the raw AM envelope detector with a limiter-derived coherent detector and update backend tests for the current channel DSP constructor.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:48:22 +01:00
sjg 081d2d062d [fix](trx-vdes): soften decode plausibility gating
Keep geo sanity checks, but treat most marginal VDES decodes as low confidence instead of rejecting them outright.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:39:32 +01:00
sjg 8d06d73e60 [fix](trx-vdes): reject implausible parsed frames
Score parsed VDES payloads and fall back to unsynced output for obviously weak or invalid decodes, including invalid geo boxes.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:35:49 +01:00
sjg 9f43cfd5e8 Revert "[fix](trx-vdes): force long bursts to finalize"
This reverts commit e9188ce400.
2026-03-03 01:32:09 +01:00
sjg e9188ce400 [fix](trx-vdes): force long bursts to finalize
Keep the current VDES thresholds but restore a maximum burst duration so continuously open detections still produce frames.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:30:57 +01:00
sjg 1755608d58 [fix](trx-vdes): rebalance burst thresholds
Restore the more permissive intermediate VDES burst thresholds while keeping the current detector reset and close behavior.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:29:26 +01:00
sjg 67c7b5d1d3 [feat](trx-rs): map VDES positions and restore burst gating
Publish decoded VDES positions into the map and revert the VDES burst detector to its original gating thresholds.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:27:33 +01:00
sjg c454f2218d [fix](trx-vdes): tighten burst detector gating
Raise the VDES burst thresholds and minimum burst length to cut false positives after the recent sensitivity increase.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:24:39 +01:00
sjg 906db0aee2 [fix](trx-rs): handle VDES audio messages and offset sweet spot
Recognize VDES decode frames in the audio client and keep sweet-spot scans from centering directly on the tuned frequency.

Co-authored-by: Stan Grams <sjg@haxx.space>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 01:22:09 +01:00
sjg 696ba09049 [fix](trx-rs): improve marine decode paths
Finish the pending MARINE frontend and decoder activation wiring, and lower the VDES detector power floors so weak signals are eligible for burst detection in the same power domain used by the IQ path.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 01:16:14 +01:00
sjg 10f5a147dc [fix](trx-vdes): preserve higher SDR iq rate
Keep the SoapySDR VDES and MARINE IQ path at a much higher channel sample rate instead of collapsing toward the normal audio rate, so the decoder receives usable complex baseband for the 76.8 ksps VDES signal.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 01:13:06 +01:00
sjg 5174e1ab73 [fix](trx-vdes): make burst detection more permissive
Lower the VDES burst detector thresholds further, shorten the minimum burst and end timing, and add a max-burst timeout so weak or continuous signals are more likely to finalize into diagnostic frames instead of staying invisible.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 01:07:00 +01:00
sjg c6af684d6c [fix](trx-vdes): lower burst detector thresholds
Reduce the VDES burst detector trigger and sustain thresholds so weaker received bursts enter and finalize more readily instead of being ignored by the IQ-side detector.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 01:04:14 +01:00
sjg 9fe293e056 [feat](trx-rs): expose marine mode and ft8 live bar
Add an FT8 live overlay bar, align APRS top controls with the other decoder tabs, advertise MARINE in the SoapySDR mode list, and make the VDES decoder emit raw unsynced diagnostic frames instead of dropping weak bursts outright.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 01:01:00 +01:00
sjg c8d81ed353 [feat](trx-rs): add marine mode scaffolding and VDES fallback
Keep weak VDES bursts visible by emitting unsynced diagnostic frames instead of dropping them, remove receiver badges from FT8 and WSPR history rows, and carry the current MARINE composite-mode scaffolding through the shared mode enums and backend mappings.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 00:54:31 +01:00
sjg 40b235e030 [feat](trx-rs): parse VDES message headers
Add a first VDES payload parser on top of the decoded bitstream so the server surfaces message labels, source and destination IDs, session IDs, ASM IDs, ack fields, geographic hints, and payload previews. Update the VDES frontend pane to render those parsed fields in the history and live bar.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 00:45:09 +01:00
sjg 5e84fe2a82 [feat](trx-rs): add VDES hard-decision FEC stage
Improve the VDES decoder with sync/rotation metadata and a first hard-decision rate-1/2 Viterbi stage after deinterleaving, then surface the extra lock state in the VDES frontend. Also fix the strict clippy findings in AIS, frontend bookmarks, and the server audio stack signature.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 00:39:41 +01:00
sjg 6e558303a7 [feat](trx-rs): split VDES frontend and decoder path
Add a dedicated VDES plugin tab and live bar, stop reusing the AIS vessel UI, and serve a separate VDES frontend script. Rework the SDR backend so VDES receives a single 100 kHz IQ tap, then replace the fake AIS-clone decoder path with an early M.2092-1 oriented complex-baseband scaffold using burst detection, coarse pi/4-QPSK slicing, and TER-MCS-1.100 frame heuristics.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-03-03 00:32:32 +01:00
sjg 92423f1e02 [feat](trx-rs): add VDES decoder mode support
Add a new trx-vdes decoder path alongside AIS, wire VDES through the server/frontend decode pipeline, and fix the web map so AIS vessel symbols load correctly and the TRX receiver marker appears when location data arrives.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-03 00:05:16 +01:00
sjg 051d07eaab Revert "[fix](trx-ais): restore adaptive AIS symbol timing"
This reverts commit e7b38c52f7.
2026-03-02 23:56:53 +01:00
sjg e7b38c52f7 [fix](trx-ais): restore adaptive AIS symbol timing
Bring back the transition-locked AIS sampler with adaptive symbol timing and shaped discriminator filtering while keeping the shorter-frame acceptance path.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 23:52:58 +01:00
sjg 8bb0497066 [feat](trx-frontend): add AIS vessel symbols on map
Render AIS vessels with heading-aware ship symbols, keep selected tracks on click, and size the map to fit the viewport cleanly without overextending the page.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 23:50:55 +01:00
sjg ff915953b9 [style](trx-frontend): refine AIS map tracks and history layout
Show AIS vessel tracks only for the selected marker, keep the APRS and AIS history panes viewport-sized with internal scrolling, and tighten the APRS history controls with shorter bookmark-scale buttons.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 23:46:58 +01:00
sjg dfc4430413 [feat](trx-frontend): rebuild APRS history viewer
Replace the APRS plugin log with a richer history view that adds summaries, filtering, pause/resume, duplicate collapsing, structured rows, row actions, and expandable details.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 23:40:27 +01:00
sjg bc50429559 [fix](trx-rs): restore AIS decoder and enforce SDR limits
Revert the AIS decoder to the simpler sampling path while keeping the valid frame-length fix, and correct frontend frequency-range validation so SDR uses all reported bands and shows an explicit popup when tuning is unsupported.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 23:34:01 +01:00
sjg 3099ae7d68 [feat](trx-frontend): improve AIS decode and decoder views
Improve the AIS decoder timing recovery, add AIS vessel linking and map trails, and make the AIS/APRS decoder panels behave like mode-bound views with full-height history panes.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 23:28:41 +01:00
sjg 27f45feedd [style](trx-frontend): polish AIS panel and bookmark wrapping
Refine the AIS plugin tab with summary cards, clearer vessel rows, and better live-bar deduping, and let long side bookmark names wrap cleanly inside their chips.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 23:13:54 +01:00
sjg c778d4b9a8 [feat](trx-rs): add AIS decoder mode and frontend
Add dual-channel AIS decode support across the SoapySDR backend, server decode pipeline, and frontend plugins, including the new AIS tab, live bar, and map filtering.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 22:42:12 +01:00
sjg b6692b759e [style](trx-frontend): dim placeholder gaps in RDS PS
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 21:54:56 +01:00
sjg 91a55554ac [chore](trx-rs): ignore user-defined local files
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 21:51:32 +01:00
sjg 9ec0ba6545 [style](trx-frontend): rename amber and fire themes
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 21:51:24 +01:00
sjg a48c11c1fe [style](trx-frontend): deepen blood theme reds
Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 21:46:14 +01:00
sjg eb6165e969 [feat](trx-frontend): add blood theme and rename fire
Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 21:46:13 +01:00
sjg 67352032e9 [style](trx-frontend): remove bookmark side shading
Co-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 21:46:09 +01:00
sjg 518e10c36a [fix](trx-frontend): refine spectrum layout behavior
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 20:36:22 +01:00
sjg 89e44f3fa7 [feat](trx-frontend): extend spectrum bookmark navigation
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 20:11:54 +01:00
sjg 49f0de75f0 [fix](trx-client): default spectrum guard ratio to 0.92
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 20:05:59 +01:00
sjg 50f8c12487 [feat](trx-client): configure spectrum guard tuning
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 20:00:50 +01:00
sjg 5ed55c6103 [style](trx-frontend): retune bookmark theme accents
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 19:46:00 +01:00
sjg 1aae38aa4c [feat](trx-frontend): add spectrum sweet-spot scan
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 19:32:52 +01:00
sjg e57cf95320 [style](trx-frontend): align spectrum shift arrows
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 19:19:08 +01:00
sjg 88203fb043 [fix](trx-frontend): retune center on bookmark tune
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 19:09:49 +01:00
sjg d5bf8f5c43 [style](trx-frontend): move spectrum shift arrow outward
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 19:05:11 +01:00
sjg 1deac71f82 [fix](trx-frontend): refine spectrum coverage controls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 18:37:02 +01:00
sjg fda883533d [feat](trx-frontend): track moving aprs stations on map
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 18:25:13 +01:00
sjg 1b6acb0fca [feat](trx-frontend): improve spectrum tuning controls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-02 18:16:36 +01:00
sjg 17be874ee3 [style](trx-frontend): remove rds frame counter
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 23:10:03 +01:00
sjg 3d7ac6f6ed [feat](trx-frontend): bookmark markers, category colors, text filter
- Move bookmark frequency markers from drawSpectrum() to drawSignalOverlay()
  so dashed lines span both the waterfall and waveform canvases
- Assign distinct palette colors to named categories; uncategorised uses
  --accent-yellow resolved from the live theme at runtime
- Compute WCAG-compliant foreground color (dark/light) per category so label
  text is always legible against the solid background
- Add text search input to the Bookmarks toolbar; filters by name, category,
  and comment client-side without re-fetching from the server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 23:00:05 +01:00
sjg 55e0a62e4b [feat](trx-frontend-http): assign distinct colours to bookmark categories
Named categories are sorted alphabetically and assigned colours from an
8-colour palette (blue, green, orange, red, purple, teal, pink, indigo).
Uncategorised bookmarks fall back to --accent-yellow (the leading UI
colour). Both the canvas dashed lines and the axis span labels (icon,
text, border, background) reflect the category colour via CSS custom
properties --bm-cat-color / --bm-cat-bg / --bm-cat-border.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:30:36 +01:00
sjg 0af9b73bb9 [feat](trx-frontend-http): show bookmark comment in spectrum label tooltip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:22:16 +01:00
sjg 5054fdd25f [style](trx-frontend-http): rename Del button to Delete in bookmarks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:17:15 +01:00
sjg 2ffb341dac [style](trx-frontend-http): transparent axis row, opaque bookmark labels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:12:17 +01:00
sjg 55e506ce2e [style](trx-frontend-http): solid background and no border on bookmark axis
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:10:14 +01:00
sjg a4a21181db [fix](trx-frontend-http): move bookmark axis to top of waterfall
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:07:47 +01:00
sjg 37ffeac5f0 [fix](trx-frontend-http): hover bookmark axis over waterfall/waveform seam
Switch #spectrum-bookmark-axis from position:relative (creates gap) to
position:absolute at top:var(--spectrum-plot-height) with
transform:translateY(-50%), so it floats centred on the boundary
without affecting layout. z-index:6 keeps it above the BW/freq
selector signal overlay (z-index 4).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:06:44 +01:00
sjg 8b94c5e0bd [feat](trx-frontend-http): move bookmark axis between waterfall and waveform
Relocate #spectrum-bookmark-axis from inside .spectrum-wrap to the
flex gap between .overview-strip (waterfall) and #spectrum-panel
(waveform). Give it z-index:5 so labels sit above the signal-overlay-
canvas (BW/freq selector, z-index:4). Drop the now-unneeded
#spectrum-freq-axis.bm-axis-open border-radius hack and the
corresponding JS class toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 20:03:57 +01:00
sjg 18ebf24115 [style](trx-frontend-http): sort bookmark list by frequency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:55:48 +01:00
sjg 5dac587233 [style](trx-frontend-http): make bookmark markers follow theme accent colour
Replace hardcoded amber values with var(--accent-yellow) throughout the
bookmark axis labels, so they automatically adapt to all UI themes.
Centre spans vertically with top:50%/translate(-50%,-50%) for equal
padding above and below. Canvas dashed line uses pal.waveformPeak
(already theme-aware) at 0.65 opacity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:53:10 +01:00
sjg 5e3f8791b5 [fix](trx-frontend-http): clean up bookmark spectrum markers and axis labels
- Canvas: remove clashing ribbon polygon; draw only a dashed amber
  vertical line at each bookmark frequency
- Axis labels: replace clip-path ribbon with inline SVG bookmark icon
  (amber rectangle with V-notch) + name text; add more padding
- DOM rebuild: only rebuild axis spans when the set of visible bookmark
  IDs changes; always update left positions for smooth pan/zoom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:49:35 +01:00
sjg 53da1570ec [fix](trx-frontend-http): fix bookmark markers invisible on spectrum
let-declared bmList is not a window property, so window.bmList in
app.js always returned undefined. Change to var so it lands on window;
read it via typeof guard in app.js to stay safe if bookmarks.js is absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:43:33 +01:00
sjg 473bbb280a [style](trx-frontend): add golden rain theme
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:42:09 +01:00
sjg 587b06c6d8 [feat](trx-frontend-http): overlay bookmarks on spectrum; enforce one per freq
Draw bookmark frequency markers on the spectrum canvas: amber vertical
line + ribbon shape (rectangle with V-notch) at each bookmark in view.
Below the freq axis, show a #spectrum-bookmark-axis row of clickable
amber ribbon labels (clip-path bookmark shape); clicking tunes the rig.
Labels auto-appear / collapse as bookmarks scroll in and out of view.

Server: reject POST/PUT with 409 Conflict when another bookmark already
exists at the requested freq_hz (BookmarkStore::freq_taken helper).

Client: bmFetch() triggers a spectrum redraw so markers appear
immediately on load without requiring a tab visit first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:39:09 +01:00
sjg e80f71ced6 [style](trx-frontend-http): make bookmark decoders selectable; gate add on control role
Replace the free-text decoder input with FT8/WSPR checkboxes so users
cannot enter arbitrary decoder strings. Hide the "+ Add Bookmark" button
by default and show it only when the authenticated role is `control`
(or auth is disabled), matching the server-side write-endpoint guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:30:59 +01:00
sjg 832fac1d64 [fix](trx-rs): refine wfm denoise and favicon handling
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:26:04 +01:00
sjg 616ff1b79e [feat](trx-backend-soapysdr): add wfm denoise levels
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:22:02 +01:00
sjg dc3c99b0ee [feat](trx-frontend-http): add Bookmarks tab to web UI
Add a "Bookmarks" tab between Main and Plugins in the tab bar.

HTML: tab panel with toolbar (category filter + Add Bookmark button),
an inline add/edit form (hidden by default, prefills freq/mode/BW from
the current rig state), and a sortable table showing all columns with
Tune / Edit / Del action buttons.

CSS: responsive bm-* classes following existing card/button theming,
works in both dark and light modes and all palette variants.

bookmarks.js: fetches bookmarks on tab activation, renders table with
event delegation, handles create/update/delete via REST, and applies a
bookmark by calling set_freq → set_mode → set_bandwidth, plus toggles
FT8/WSPR decoders when the stored mode is DIG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:17:15 +01:00
sjg 32a7629e6a [feat](trx-frontend-http): add bookmark CRUD REST endpoints
GET /bookmarks          — list all (optional ?category= filter); rx role
POST /bookmarks         — create; control role enforced in handler
PUT /bookmarks/{id}     — update; control role enforced in handler
DELETE /bookmarks/{id}  — remove; control role enforced in handler

Auth middleware classifies /bookmarks and /bookmarks/* as Read so rx
users can reach GET; write handlers call require_control() to reject
lower-privileged sessions with 403.

Also serves bookmarks.js via GET /bookmarks.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:17:01 +01:00
sjg 34f6b42330 [feat](trx-frontend-http): add BookmarkStore backed by pickledb
Add pickledb and dirs dependencies. Introduce BookmarkStore wrapping
PickleDb behind Arc<RwLock<>> with list/get/insert/upsert/remove ops.
Keys stored as "bm:{id}" in ~/.config/trx-rs/bookmarks.db; falls back
to ./bookmarks.db when config dir is unavailable.

Wire BookmarkStore into build_server() as actix-web app_data so all
request handlers can share the store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:16:50 +01:00
sjg 31238098ca [fix](trx-backend-soapysdr): raise wfm output gain
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:07:29 +01:00
sjg 623aad10d1 [fix](trx-backend-soapysdr): preserve more stereo under denoise
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:06:15 +01:00
sjg fb52558652 [feat](trx-backend-soapysdr): adapt wfm stereo separation gain
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 19:01:08 +01:00
sjg d569db6643 [fix](trx-backend-soapysdr): fine tune wfm phase trim
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:53:12 +01:00
sjg 2b1764b90e [fix](trx-backend-soapysdr): restore neutral wfm stereo trim
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:50:54 +01:00
sjg a1a8aaeb9d [fix](trx-backend-soapysdr): rebalance wfm output gain
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:48:51 +01:00
sjg 129c27548e [fix](trx-backend-soapysdr): reduce wfm output gain
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:46:49 +01:00
sjg ded6e2a1f8 [fix](trx-backend-soapysdr): widen wfm stereo image
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:43:07 +01:00
sjg fbd881af7b [fix](trx-frontend): add peak hold off and scale waterfall floor
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:38:17 +01:00
sjg 0244839a4f [fix](trx-frontend): restore spectrum peak hold
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:34:13 +01:00
sjg 353ce517ec [style](trx-frontend): shorten aprs live bar window
Limit the APRS live bar to the last 15 minutes and show that
window in the overlay header.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:23:07 +01:00
sjg 00f5108a58 [feat](trx-client): support website_name in header
Add an optional website_name config field and prefer it over
callsign for the linked web header title label.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:19:36 +01:00
sjg 31b40fc1ef [fix](trx-frontend): gate RDS display updates on WFM mode
updateRdsPsOverlay was called on every spectrum frame (25 Hz)
regardless of mode, doing 15+ DOM element lookups and text updates
even in USB/AM/CW/etc.  The server-side RDS DSP already only runs
in WFM; align the client:

- Spectrum SSE handler: only increment rdsFrameCount and call
  updateRdsPsOverlay when lastModeName === "WFM"
- Mode change: call resetRdsDisplay() when switching to or from WFM
  so the overlay and RDS panel are cleared promptly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:14:56 +01:00
sjg e337b6d2de [feat](trx-client): link web header title to website
Add an optional website URL to config and use it for the web header
title when present, falling back to the version title otherwise.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:12:31 +01:00
sjg 5899592b6c [fix](trx-rs): limit aprs live bar to one hour
Attach replay timestamps to APRS history and filter the APRS live bar
so it only shows the last hour of valid entries.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:08:38 +01:00
sjg 63fd35802e [feat](trx-frontend): add Neon Disco colour scheme
Deep purple-black backgrounds, neon magenta (#ff10e0) as primary
accent and neon green (#39ff14) as secondary.  Waterfall sweeps
hue 300→120 (magenta→green).  Light variant uses muted (#cc00a8 /
#1f8800) counterparts on a lavender-tinted white background.

- style.css: dark + light CSS variable blocks for neon-disco
- app.js: CANVAS_PALETTE entry; "neon-disco" added to valid styles
- index.html: Neon Disco option in the style picker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:06:17 +01:00
sjg 08a91ebe43 [style](trx-frontend): theme receiver marker via CSS var
Remove hardcoded #3388ff from the TRX circleMarker; apply
.trx-receiver-marker class and use stroke/fill: var(--accent-green)
so the dot follows the active colour scheme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:02:33 +01:00
sjg e2a9e4d610 [feat](trx-frontend): rich popup for receiver/owner marker on map
Replace plain-text receiver marker popup with a styled info card
matching the APRS popup layout.  Shows callsign, trx-server version
and build date, owner callsign (when different), QTH coordinates,
and all configured rigs with manufacturer/model; active rig is
badged.

Rig data (manufacturer, model, display_name, active state) is stored
in serverRigs/serverActiveRigId on each /rigs refresh.  Popup content
is rebuilt live on popupopen so it always reflects current state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 18:00:22 +01:00
sjg 2188d62069 [feat](trx-frontend): rich popup for receiver/owner marker on map
Replace plain-text receiver marker popup with a styled info card
matching the APRS popup layout.  Shows:
- Station callsign (serverCallsign / ownerCallsign)
- trx-server version and build date
- Owner callsign (when different from station callsign)
- QTH coordinates
- All configured rigs with manufacturer/model; active rig badged

Rig data (manufacturer, model, display_name, active state) is
stored in serverRigs/serverActiveRigId on each /rigs refresh.
Popup content is rebuilt live on popupopen so it always reflects
the current state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 17:58:48 +01:00
sjg c2add05a7c [fix](trx-frontend): use server decode history only
Remove APRS client-side persistence, reset decode views before replay,
and clear decode panes only after the server clears its history.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 17:58:16 +01:00
sjg 0e6d2c0c47 [style](trx-frontend): theme radio path line via CSS var
Remove hardcoded #3388ff colour from L.polyline options; use
stroke: var(--accent-green) and stroke-opacity in the CSS class
so the path follows the active colour scheme automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 17:53:52 +01:00
sjg 1ef1da7c0d [feat](trx-frontend): show animated radio path on APRS station click
Draw a blue dashed polyline from the receiver to the clicked APRS
station on popup open; remove it on popup close.  CSS stroke-dashoffset
animation creates a traveling-dash effect suggesting signal propagation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 17:50:39 +01:00
sjg 6e9d0040bb [fix](trx-frontend): preserve APRS reception timestamp across page refresh
Previously aprsMapAddStation forced Leaflet map init on a hidden element
during history restore.  When live packets later arrived they stamped a
fresh _tsMs, resetting the displayed age to "0s ago".

Decouple data storage from Leaflet rendering:
- aprsMapAddStation now stores station data in stationMarkers immediately
  (with the original _tsMs from localStorage) without touching the map
- _aprsAddMarkerToMap creates Leaflet markers only when the map is ready
- initAprsMap materialises all buffered APRS entries on first open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 17:47:43 +01:00
sjg 42e73f5eeb [fix](trx-frontend): fix APRS map popup age, distance, and dark theme
- Rebuild popup content on popupopen event so age and distance
  are always computed fresh at the moment of opening; store
  _aprsCall on each marker for O(1) lookup
- Extend map to fill viewport down to the footer instead of 60%
- Override Leaflet popup background/color to use CSS theme vars,
  fixing invisible text in dark theme

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 17:27:38 +01:00
sjg db425156a4 [feat](trx-frontend-http): rich APRS map tooltips with distance and age
Each station popup now shows:
- Callsign/SSID header
- Age (s/min/h ago, from _tsMs stamped on receive)
- Distance from receiver (Haversine, km or m)
- Packet type and via path
- Full info/comment string

Adds haversineKm(), formatTimeAgo(), buildAprsPopupHtml() helpers in
app.js and .aprs-popup-* CSS. Passes full packet object as 7th arg
to aprsMapAddStation from aprs.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 17:19:00 +01:00
sjg dd051beee3 [style](trx-frontend-http): match APRS tab clear button to CW reference
Remove custom #aprs-clear-btn CSS overrides and SVG icon; reduce to a
plain <button>Clear</button> so it inherits the same global button
style as the CW clear button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 16:37:37 +01:00
sjg f537e1a11b [feat](trx-client): add configurable initial map zoom
Add an HTTP frontend config option for the initial APRS map zoom,
expose it through frontend metadata, and apply it in the web UI.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 16:35:12 +01:00
sjg 8c6c370563 [style](trx-frontend): rebalance aprs overlay title spacing
Tighten the spacing inside the APRS title while adding more room
between the title and the clear action.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:33:32 +01:00
sjg 1ae44a714f [style](trx-frontend): reduce aprs clear text size
Scale the APRS overlay inline clear text down for better balance
with the enlarged APRS title.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:31:28 +01:00
sjg 21a019a0fa [style](trx-frontend): enlarge aprs overlay title
Increase the APRS overlay header base text size so the title reads
larger alongside the clear action.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:28:50 +01:00
sjg 81dca146cf [style](trx-frontend): enlarge aprs clear text
Increase the APRS overlay inline clear text size for stronger
readability.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:25:49 +01:00
sjg 5b3d0c0865 [style](trx-frontend): increase aprs overlay text size
Increase the APRS overlay header text and make the inline clear text
match the row's base text size.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:24:32 +01:00
sjg aa73a4bc00 [style](trx-frontend): simplify aprs overlay clear action
Render the APRS overlay clear action as inline clickable text inside
a span wrapper instead of a button-like control.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:23:08 +01:00
sjg eec147c26f [style](trx-frontend): reduce aprs overlay header height
Cut the APRS overlay header sizing further so the row reads much
smaller and no longer dominates the overlay.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:20:46 +01:00
sjg 579ce31bbd [style](trx-frontend): inset aprs overlay from edges
Offset the APRS overlay slightly from the waterfall edges so it reads
as a hovering strip and leaves the waterfall visible at the sides.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:20:08 +01:00
sjg 69253959c8 [style](trx-frontend): shrink aprs overlay clear control
Reduce the APRS overlay clear control so it no longer sets the
header row height.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:18:42 +01:00
sjg 17a643d070 [style](trx-frontend): polish aprs overlay clear control
Wrap the APRS overlay clear action in a styled span and place it
next to the APRS title for a tighter header layout.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:16:20 +01:00
sjg 26e51447d7 [style](trx-frontend): tighten aprs overlay header
Reduce the APRS overlay header height by trimming spacing and scaling
down the clear control chrome.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:14:39 +01:00
sjg b24d6663a2 [style](trx-frontend-http): align RDS bar border-radius with UI standard
Replace 999px pill on #rds-ps-overlay with 6px to match the rest of
the UI button rounding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:13:20 +01:00
sjg 436e1d8e23 [style](trx-frontend-http): align APRS bar border-radius with UI standard
Replace 999px pill radii with 6px (overlay), 4px (clear btn, pin),
3px (clear icon) — matching the standard button radius used throughout
the rest of the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:12:53 +01:00
sjg b6848c0c8e [style](trx-frontend): refine aprs web controls
Restyle the APRS clear button and tighten the APRS overlay bar to use
more compact, polished chrome while keeping the existing behavior.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:06:36 +01:00
sjg 39c4f1d800 [style](trx-frontend-http): tighten APRS bar entry spacing further
Reduce line-height to 1.05, use 0.1rem vertical padding + hairline
border-bottom to separate entries instead of whitespace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:01:59 +01:00
sjg c60b764acb [style](trx-frontend-http): restyle APRS bar clear button as slim rect
Smaller font (0.65em), horizontal padding only, line-height-based
height, tighter border-radius — produces a flat wide rectangle tag
rather than a square button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 15:00:52 +01:00
sjg 1b34d75a31 [style](trx-frontend-http): sticky APRS bar header on scroll
Make the APRS/Clear header row position:sticky so it stays visible
when the frame list is scrolled. Adds a subtle backdrop-blur background
and a divider border so frames slide cleanly beneath it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:59:40 +01:00
sjg 8359beae5c [style](trx-frontend-http): tighten APRS bar entry spacing
Remove inter-frame gap and reduce line-height 1.45→1.2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:59:09 +01:00
sjg 3e5df90c8f [style](trx-frontend-http): APRS bar clear button rounded rect
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:58:02 +01:00
sjg 8e353620de [style](trx-frontend-http): replace coord sub-line with inline pin button
When a frame carries a position, show a small 📍 button between the
timestamp and callsign on the same line. Title tooltip shows the
coordinates; clicking navigates to the Map tab. Removes the two-line
per-entry layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:56:24 +01:00
sjg a7ba1eec5f [style](trx-frontend-http): tighten APRS bar spacing, shrink clear btn, remove cap
- Reduce frame line-height 1.45→1.3 and pos sub-line to 1.1 to close
  the gap between the info and coordinate rows within each entry
- Shrink clear button (smaller padding, 0.78em font)
- Remove 5-entry cap; full history visible via scroll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:55:37 +01:00
sjg 7c810729ed [fix](trx-frontend-http): fix APRS bar coords visibility, cap at 5 entries
Coordinates were clipped by white-space:nowrap on the frame row.
Split each frame into a .aprs-bar-frame-main line (nowrap+ellipsis)
and a separate .aprs-bar-frame-pos line for the coordinate button,
so coordinates are always visible regardless of info length.
Restore 5-entry cap (slice from history).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:51:02 +01:00
sjg 5987a0b4a8 [feat](trx-frontend-http): add Clear button to APRS bar overlay
Small pill-shaped button in the header row, right-aligned. Clicking
it delegates to the existing aprs-clear-btn, clearing history, the
packet panel, and the bar in one shot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:49:48 +01:00
sjg 37966ea29a [feat](trx-frontend-http): clicking APRS bar coords navigates to Map
Add window.navigateToAprsMap(lat, lon) which activates the Map tab
and pans to the given position at zoom 13. APRS bar frames that carry
a position render a clickable coordinate button that calls this
function. Button is styled inline with the frame text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:49:04 +01:00
sjg 8ffe73c539 [fix](trx-server): drop CRC-failed APRS frames before sending to clients
Guard both the live broadcast (decode_tx.send) and the history store
(record_aprs_packet) so CRC-failed packets are never forwarded to
connected clients or replayed on reconnect. The decode logger still
receives all frames for debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:47:54 +01:00
sjg df612aae00 [fix](trx-frontend-http): APRS bar shows full history, no truncation
Drop APRS_BAR_MAX cap and the separate aprsBarFrames ring buffer;
drive the bar directly from aprsPacketHistory (CRC-ok frames only).
Remove the 60-char info truncation. CSS opacity fading still kicks in
after frame 5; older frames are reachable by scrolling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:46:40 +01:00
sjg b552829c95 [style](trx-frontend-http): APRS bar spans full waterfall width
Stretch overlay edge-to-edge (left/right: 0), drop side borders and
border-radius for a full-width band look. Also enable pointer-events,
user-select, and overflow-y: auto so frames are scrollable and
copy-able.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:44:11 +01:00
sjg 350087855f [fix](trx-frontend-http): gate APRS bar to PKT mode, reject CRC failures
- Hide bar whenever mode != PKT; app.js calls window.updateAprsBar()
  on every server-pushed mode change so the bar disappears immediately
- CRC-failed frames are excluded from the bar (both live and history
  restore); the [CRC] rendering path is removed
- Offset bar 1.2 rem from left edge for visual breathing room

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:41:39 +01:00
sjg cbefdc15c4 [docs](trx-frontend): rename audio control labels
Update the web audio control labels in the markup and initialize the
same labels from JavaScript for consistency.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:41:13 +01:00
sjg aa367239de [fix](trx-rs): update PKT and AM default bandwidths
PKT: default 25 kHz, max 50 kHz (was 3 kHz / 25 kHz).
AM:  default 9 kHz,  max 20 kHz (was 6 kHz / 15 kHz).

Applied consistently across the UI mode-defaults table
(trx-frontend-http), server initial-mode logic (trx-server), and the
SoapySDR DSP channel (trx-backend-soapysdr).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:37:28 +01:00
sjg 62c5889619 [feat](trx-frontend-http): add APRS bar overlay on overview strip
Show the last 5 received APRS frames as a compact overlay in the
bottom-left corner of the waterfall strip, styled similarly to the
RDS PS overlay (backdrop blur, pill border). Frames fade out by
recency via CSS sibling-selector opacity steps. Bar auto-hides when
empty and is cleared by the APRS clear button. Restored from
localStorage on page load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:37:17 +01:00
sjg fce024b090 [style](trx-frontend): improve mobile web layout
Refine the mobile layout for the web frontend, add a sticky bottom tab bar,
and style the GitHub footer link as a badge.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:32:36 +01:00
sjg 99ff619b9a [docs](trx-rs): add architecture and code design overview
Adds OVERVIEW.md with a comprehensive description of the project
architecture, crate layout, key types, data flow diagrams, DSP
pipeline, plugin system, and configuration reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 14:25:17 +01:00
sjg 56d6d12d9e [refactor](trx-backend-soapysdr): extract spectrum snapshot helper
Move the spectrum FFT snapshot logic into a dedicated dsp module so dsp.rs stays focused on pipeline orchestration.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 13:58:30 +01:00
sjg 08b8c80cc3 [refactor](workspace): split soapysdr demod and dsp modules
Split the SoapySDR backend demod and dsp code into focused modules while keeping behavior stable, and include the resulting formatting updates.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 13:55:48 +01:00
sjg 441cdd3adb [style](trx-frontend): use select element for WFM denoise control
Replace the checkbox with an On/Off select dropdown to match the
styling of the other WFM controls (Deemp, Audio) in the controls row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 13:41:13 +01:00
sjg 64bb9a0d42 [feat](trx-rs): expose WFM stereo denoise toggle in UI
Wire the StereoDenoise processor through the full stack:
- Add SetWfmDenoise command variant and RigCat trait method (trx-core)
- Add wfm_denoise field to RigFilterState with default true
- Add protocol command and bidirectional mapping (trx-protocol)
- Add rig_task command handler (trx-server)
- Implement set_wfm_denoise in SoapySdrRig backend
- Add /set_wfm_denoise API endpoint (trx-frontend-http)
- Add denoise checkbox in WFM controls with SSE sync and
  localStorage persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 13:29:02 +01:00
sjg 46bc96ddf2 [fix](workspace): resolve clippy warnings
Clean up the workspace so cargo clippy passes across all targets and features.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 13:22:44 +01:00
sjg 523f3992f5 [feat](trx-backend-soapysdr): add WFM stereo denoise processor
Add frequency-selective attenuation of the L-R difference signal to
reduce stereo hiss on weak FM broadcasts. Uses the quadrature component
(diff_q) as a noise reference per US7292694B2 (Wildhagen/Sony).

The algorithm splits sum, diff_i, and diff_q into 6 overlapping subbands,
estimates per-band SNR from smoothed |diff_q|² noise power, and applies
an energy-weighted broadband gain to the original diff signal. This
preserves clean stereo content (<4 dB loss) while attenuating noise-only
diff channels (>6 dB reduction).

Enabled by default; toggled via set_denoise_enabled() / set_wfm_denoise().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 13:17:30 +01:00
sjg cb5467ba34 [fix](trx-backend-soapysdr): retune wfm stereo gain
Increase the stereo matrix gain to 1.2 and trim the WFM output gain slightly to rebalance the decoded audio path.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:28:30 +01:00
sjg d133b9082b [fix](trx-frontend): disable marker overlay for non-sdr
Collapse the shared signal marker overlay when no spectrum data is available so the non-SDR signal graph renders cleanly.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:22:24 +01:00
sjg ca6b83c967 [fix](trx-backend-soapysdr): raise wfm stereo matrix gain to unity
Output gain clamp catches any peaks from full-deviation signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:22:15 +01:00
sjg 313c9b39a6 [fix](trx-backend-soapysdr): increase wfm stereo matrix gain to 0.80
More stereo headroom for broadcast audio. With WFM_OUTPUT_GAIN at 0.35
the effective output is 0.28 peak, well within clipping margin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:19:50 +01:00
sjg f7c10a0185 [fix](trx-backend-soapysdr): remove agc from wfm chain, use fixed output gain
Replace both IQ AGC and audio AGC in the WFM path with a fixed output
gain of 0.35. AGC pumping on broadcast audio degrades stereo separation
and introduces audible artifacts. The IQ hard limiter already normalizes
input magnitude, and the WFM decoder's internal deemphasis + matrix gain
provides consistent output levels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:17:14 +01:00
sjg c2b287e000 [fix](trx-frontend): fix audio callback parse error
Rename the duplicate callback-local variable so app.js loads cleanly and dependent plugin scripts can access shared helpers.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:08:22 +01:00
sjg 8ec442da0a [docs](trx-rs): add DSP chain performance optimization guidelines
Document lessons learned from WFM stereo decoder and audio encoding
optimization: quadrature NCO, double-angle identities, AVX2 batching,
polyphase resampler design, filter matching, stereo detection decimation,
and opus encoder tuning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:06:27 +01:00
sjg a61f901024 [fix](trx-frontend): restore non-sdr signal graph and audio meter
Keep the non-SDR signal graph visible and drive the audio level bar from decoded sample levels instead of packet size.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:06:11 +01:00
sjg f9492633d9 [fix](trx-frontend): add footer project link and safari favicon fix
Add the trx-rs GitHub link in the footer and make favicon handling more explicit for Safari.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 11:03:47 +01:00
sjg f8507698f4 [fix](trx-frontend): hide signal visuals for non-sdr
Hide the combined waterfall and spectrum block when filter controls are unavailable so non-SDR rigs do not show those visuals.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:59:09 +01:00
sjg 99c6ff0e87 [fix](trx-frontend): unify signal markers and clamp axis labels
Render the BW and tuned-frequency markers on a shared overlay and keep spectrum axis labels bold and inside their box.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:54:15 +01:00
sjg 00a453f18c [perf](trx-server): set opus complexity to 5 and raise default bitrate to 256k
Set opus encoder complexity from default (9-10) to 5 for both cpal and
SDR audio paths, significantly reducing encoding CPU usage with minimal
quality impact at these bitrates. Raise default bitrate from 192 kbps to
256 kbps for higher fidelity stereo audio.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:42:00 +01:00
sjg e3c7231650 [fix](trx-frontend): group signal visuals in main tab
Wrap the waterfall and spectrum in a shared main-tab block so lower sections and other tabs keep consistent spacing.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:37:53 +01:00
sjg 6e8b23052d [fix](trx-frontend): unify markers and active theme toggle
Keep the waterfall and spectrum markers visually continuous and style the theme toggle to match the active theme.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:35:13 +01:00
sjg bfbf18ae21 [fix](trx-frontend): add room in rds overlay
Increase the RDS overlay padding and width so the waterfall badge has more breathing room.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:32:41 +01:00
sjg da2cc91925 [fix](trx-frontend): restore sdr frequency accent and waterfall gap
Keep the SDR frequency input accented without extra VFOs and restore the bottom spacing below the waterfall.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:31:35 +01:00
sjg 93108c1248 [fix](trx-frontend): tighten two-state step scale toggle
Resize the step scale control so it reads as a balanced two-option toggle instead of a leftover segmented control.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:27:11 +01:00
sjg 816780aa6b [fix](trx-frontend): compact wfm controls layout
Align the WFM options into a concise control strip with consistent sizes and spacing in the main window.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:25:52 +01:00
sjg 3ea516d27c [fix](trx-frontend): clarify tune step labels
Rename the tune-step scale labels so the divisor toggle reads clearly in the frequency controls.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:22:56 +01:00
sjg ec1ea49092 [fix](trx-frontend): show bw label only during resize
Lift the bandwidth label slightly and render it only while the bandwidth edges are being dragged.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:20:48 +01:00
sjg a2872dece5 [fix](trx-frontend): mirror spectrum controls on waterfall
Remove the gap under the waterfall and extend tuning markers plus wheel, click, and bandwidth drag interactions to the overview canvas.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:18:29 +01:00
sjg 740d678357 [fix](trx-frontend): increase spectrum headroom and match heights
Add 10 dB of spectrum headroom and keep the overview waterfall the same height as the spectrum plot.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:14:22 +01:00
sjg 626d5e2ec5 [perf](trx-backend-soapysdr): quadrature NCO and decimated stereo detection
Replace per-sample sin_cos(pilot_phase) with a quadrature NCO that
advances via complex rotation (4 muls + 2 adds vs transcendental).
Renormalize every 1024 samples to prevent magnitude drift.

Decimate stereo detection logic (pilot coherence, lock, drive,
hysteresis) to run every 16 composite samples instead of every sample.
Accumulate pilot_mag and pilot_abs over the window and process averaged
values, scaling the IIR coefficients accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:07:16 +01:00
sjg 52aeb7b694 [fix](trx-frontend): remove top bar overlap with waterfall
Keep the top bar above the waterfall and remove the rounded logo box styling.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 10:02:32 +01:00
sjg a9aee190d3 [fix](trx-frontend): move logo bar content into top bar
Move the logo/header cluster into the top bar on the left side.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:56:39 +01:00
sjg 0c35051630 [perf](trx-backend-soapysdr): eliminate per-sample sin_cos and atan2 calls
Derive sin/cos of PLL phase error directly from I/Q arms (q/mag, i/mag)
instead of calling atan2 + sin_cos. Use double-angle identity to compute
38 kHz carrier (sin2θ = 2·sinθ·cosθ, cos2θ = 2·cos²θ-1) from the
rotated pilot sin/cos, eliminating the second sin_cos call entirely.
Drop Butterworth from 6th to 4th order (resampler Blackman-Harris now
handles stopband). Use power-of-2 bitmask for ring buffer indexing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:52:51 +01:00
sjg 4f99c23e7a [perf](trx-backend-soapysdr): reduce polyphase resampler phases from 128 to 64
Halves the coefficient bank from 128×32 to 64×32 (16 KB → 8 KB) for
better L1 cache utilization while maintaining sufficient fractional
sample resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:47:13 +01:00
sjg 7eb88b6669 [perf](trx-backend-soapysdr): use circular buffer for wfm resampler history
Replace shift_append (O(N) rotate_left per sample) with a circular buffer
index for O(1) writes. The polyphase resampler now reads from the ring
buffer directly, eliminating 3 × 32-element memmoves per composite sample.
Remove unused dot_product functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:46:35 +01:00
sjg 132cd5b950 [perf](trx-backend-soapysdr): batch wfm discriminator with avx2 atan2
Pre-compute all FM discriminator outputs using demod_fm_with_prev which
processes 8 samples at a time via AVX2 atan2, then iterate the scalar
results through the rest of the stereo pipeline. Eliminates per-sample
f32::atan2 calls from the inner loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:42:49 +01:00
sjg 36d0e7e862 [perf](trx-backend-soapysdr): reduce wfm resampler taps from 64 to 32
With Blackman-Harris window and proper cutoff (~0.24), 32 taps still
provides 60+ dB stopband rejection. Halves the per-sample MAC count
from 192 to 96 across the three resampler channels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:41:26 +01:00
sjg 380231a68f [perf](trx-backend-soapysdr): improve wfm stereo resampler and pilot rejection
Increase polyphase resampler phases from 32 to 128 for finer fractional
sample positioning. Replace Hamming window with Blackman-Harris for ~92 dB
stopband rejection. Add pilot notch on composite signal before diff demod
to prevent 19 kHz × 38 kHz intermod products in the stereo difference
path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:37:11 +01:00
sjg 700e5c9fab [perf](trx-backend-soapysdr): increase wfm resampler taps and filter order
Increase polyphase resampler taps from 16 to 64 for sharper anti-alias
stopband rejection. Upgrade sum/diff lowpass filters from 4th-order to
6th-order Butterworth (three biquad stages) for ~36 dB/octave rolloff,
improving stereo separation by better rejecting the 38 kHz subcarrier
residuals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:19:11 +01:00
sjg 1ba9c9dd3e [fix](trx-backend-soapysdr): widen wfm audio bandwidth to 18 kHz
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:11:50 +01:00
sjg 4994a869f9 [fix](trx-backend-soapysdr): compute resampler cutoff from actual rate ratio
The fixed WFM_RESAMP_CUTOFF of 0.94 passed frequencies up to 94 kHz at
200 kHz composite rate, while the output Nyquist is only ~24 kHz. The
38 kHz demod products in the stereo diff path were only ~31 dB attenuated
by the Butterworth and aliased back into 10-20 kHz audio, causing treble
corruption in stereo mode. Now the cutoff is computed as
audio_rate / composite_rate, properly anti-aliasing the polyphase
resampler output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 09:07:07 +01:00
sjg a5e66ed287 [fix](trx-backend-soapysdr): revert wfm resampler taps back to 16
32 taps caused audio silence on real signals. Revert to 16 taps
which works correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 02:20:42 +01:00
sjg edb456e843 [fix](trx-backend-soapysdr): replace avx2 atan2 with high-precision polynomial
Replace the low-accuracy 0.273 linear atan approximation with a
7th-order minimax polynomial (max error ~2.4e-7 rad vs ~0.004 rad).
Use branchless |y|>|x| argument reduction instead of y/x division
with quadrant fixup, avoiding division-by-zero and NaN branches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 02:10:02 +01:00
sjg cb4b81ca94 [fix](trx-backend-soapysdr): use precise atan2 in wfm discriminator and pll
Replace fast_atan2 polynomial approximation with f32::atan2 in the WFM
stereo decoder's FM discriminator and pilot PLL. The approximation
introduced harmonic distortion (~0.22 deg error) that manifested as
treble artifacts on strong/overdeviated broadcast signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 02:04:06 +01:00
sjg 8f6a5af4ec [fix](trx-backend-soapysdr): hard-limit iq magnitude before wfm discriminator
Normalize IQ samples exceeding unit magnitude before the FM
discriminator. The discriminator only uses phase, so clamping
amplitude prevents overdeviated signals from producing clipped
composite baseband without losing frequency information.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:58:16 +01:00
sjg 13de90d664 [fix](trx-backend-soapysdr): increase wfm resampler taps from 16 to 32
Doubles the polyphase FIR length for the composite-to-audio rate
converter, improving stopband rejection from ~25 dB to ~50 dB.
This reduces treble distortion from imaging artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:55:59 +01:00
sjg 8d0dc17317 [chore](trx-backend-soapysdr): remove unused smoothstep01
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:49:27 +01:00
sjg 815efdb1ae [fix](trx-backend-soapysdr): match sum/diff filters and simplify stereo blend
- Set STEREO_DIFF_BW_HZ = AUDIO_BW_HZ so both filter paths have
  identical group delay (improves multitone separation by ~10 dB).
- Zero out STEREO_SEPARATION_PHASE_TRIM (unnecessary with matched filters).
- Replace gradual blend ramp with binary blend: full stereo at pilot
  lock, mono when unlocked. The hysteresis thresholds already handle
  noisy signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:48:16 +01:00
sjg 13576f73aa [test](trx-backend-soapysdr): add multi-tone wfm stereo separation test
Test L-only and R-only signals with tones at 400, 2000, 8000 and
14000 Hz to catch frequency-dependent group delay and phase trim
issues that the single 1 kHz test misses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:42:39 +01:00
sjg 33b218fce3 [perf](trx-backend-soapysdr): reduce frame emission overhead
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:41:34 +01:00
sjg 2ed4aaf46d [fix](trx-backend-soapysdr): tune wfm stereo filter bw and matrix gain
Restore AUDIO_BW_HZ to 15.8 kHz for cleaner mono path, widen
STEREO_DIFF_BW_HZ to 18 kHz for better high-frequency stereo detail,
and raise STEREO_MATRIX_GAIN from 0.30 to 0.50 (mathematically correct
unity gain for the L=(S+D)/2 stereo matrix).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:37:15 +01:00
sjg e7997f8492 [perf](trx-backend-soapysdr): use ring history for wfm resampler
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:34:50 +01:00
sjg a6c3a85c8d [fix](trx-backend-soapysdr): match wfm sum/diff filter cutoffs at 18 kHz
Raise both AUDIO_BW_HZ and STEREO_DIFF_BW_HZ to 18 kHz so the L+R and
L-R filter paths have identical group delay across the full audio band.
The previous mismatch (15.8 vs 14.5 kHz) caused frequency-dependent
phase errors in the stereo matrix that degraded real-world separation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:31:34 +01:00
sjg f9691fee1e [perf](trx-backend-soapysdr): vectorize wfm discriminator angle
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:30:41 +01:00
sjg 72219956d9 [perf](trx-backend-soapysdr): trim wfm resampler taps
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:21:50 +01:00
sjg b2ce62646c [fix](trx-backend-soapysdr): improve wfm stereo blend threshold
Raise the stereo blend floor from 0.55 to 0.75 at pilot lock and lower
the full-blend ceiling from stereo_detect_level 0.92 to 0.70.  This
gives real-world signals with moderate pilot strength much better L/R
separation (~17 dB immediately at lock vs ~5 dB before) and reaches
full unity blend sooner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:20:38 +01:00
sjg 926a02e043 [perf](trx-backend-soapysdr): streamline wfm decode loop
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:19:47 +01:00
sjg b42e557a25 [perf](trx-backend-soapysdr): pack iq fir filtering
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:17:16 +01:00
sjg 198efa2092 [perf](trx-backend-soapysdr): update fir overlap in place
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:13:10 +01:00
sjg 7a6467320b [perf](trx-backend-soapysdr): reuse fir scratch buffers
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:10:34 +01:00
sjg ca0f236f4a [perf](trx-backend-soapysdr): reduce mixer and buffer overhead
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:07:19 +01:00
sjg bec3fc72d3 [fix](trx-backend-soapysdr): widen wfm stereo image
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:03:05 +01:00
sjg 6f37f9c66b [fix](trx-backend-soapysdr): refine wfm stereo tracking
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:58:20 +01:00
sjg e84a0599fa [feat](trx-backend-soapysdr): add avx2 wfm hot paths
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:52:53 +01:00
sjg 3e88c3f5d9 [feat](trx-backend-soapysdr): speed up fir and widen wfm resampler
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:50:04 +01:00
sjg 5411607b63 [fix](trx-backend-soapysdr): honor frontend wfm bandwidth
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:46:08 +01:00
sjg 17c70f89e5 [fix](trx-backend-soapysdr): fully reset wfm decoder on tune
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:41:28 +01:00
sjg fdcc732c83 [fix](trx-backend-soapysdr): retune wfm fir window
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:38:44 +01:00
sjg b0d8ce8e29 [fix](trx-backend-soapysdr): restore wfm agc and cutoffs
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:35:19 +01:00
sjg a6847742d9 [fix](trx-backend-soapysdr): keep wfm discriminator state across blocks
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:31:41 +01:00
sjg a8ed9e8fa4 [fix](trx-backend-soapysdr): lower wfm internal rate ceiling
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:24:33 +01:00
sjg a25182ea3f [fix](trx-backend-soapysdr): drop wfm limiter and widen cutoffs
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:20:41 +01:00
sjg 3b277da243 [fix](trx-backend-soapysdr): make wfm limiter bass-insensitive
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:10:54 +01:00
sjg 206696acc5 [feat](trx-backend-soapysdr): add adaptive wfm stereo blend
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:07:13 +01:00
sjg 5060522541 [fix](trx-backend-soapysdr): add wfm limiter and restore cutoffs
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:02:19 +01:00
sjg b6f0a9967d [fix](trx-backend-soapysdr): improve wfm stereo separation trim
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 00:00:31 +01:00
sjg 32822effda [fix](trx-backend-soapysdr): retune wfm sum and diff cutoffs
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:55:17 +01:00
sjg 635d2b39bb [fix](trx-backend-soapysdr): add stereo diff high-pass and headroom
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:51:26 +01:00
sjg 420e98de31 [feat](trx-client,trx-frontend-http): add default rig and hideable rf gain
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:45:17 +01:00
sjg 9453c90dac [fix](trx-backend-soapysdr): retune wfm stereo difference path
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:41:07 +01:00
sjg 0dd91a952b [fix](trx-backend-soapysdr): add headroom to wfm stereo matrix
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:38:18 +01:00
sjg 6d466f44d2 [fix](trx-backend-soapysdr): narrow wfm stereo difference band
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:37:08 +01:00
sjg 9e49bfbcc8 [fix](trx-backend-soapysdr): latch full stereo blend after lock
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:34:43 +01:00
sjg 617255cd32 [feat](trx-rs): expose live sdr gain control
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:30:08 +01:00
sjg 93e507606e [feat](trx-server,trx-backend-soapysdr): add sdr gain ceiling
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:23:41 +01:00
sjg dbeec9fb39 [feat](trx-backend-soapysdr): use fir resampler for wfm audio
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:17:15 +01:00
sjg 96da7ca471 [style](trx-frontend-http): lower rds overlay badge
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:12:38 +01:00
sjg 5a6ff1427a [fix](trx-client,trx-backend-soapysdr): improve rig switching and fm agc
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 23:06:53 +01:00
sjg 8440fe164b [fix](trx-backend-soapysdr): reduce wfm audio cutoff
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:54:03 +01:00
sjg 735c9a7f07 [fix](trx-backend-soapysdr): bypass agc for wfm audio
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:38:54 +01:00
sjg 3ccda5fb5a [fix](trx-backend-soapysdr,trx-frontend-http): reset stereo state and soften wfm top end
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:34:54 +01:00
sjg 3a75b7ac5b [fix](trx-frontend-http): satisfy audio copyTo arguments
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:28:35 +01:00
sjg 85ad204974 [fix](trx-frontend-http): handle stereo audio frame layouts
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:26:56 +01:00
sjg f06dbc921a [fix](trx-frontend-http): reconfigure audio stream on rig change
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:23:03 +01:00
sjg b6053729a4 [fix](trx-backend-soapysdr): emit proper stereo pcm frames
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:07:34 +01:00
sjg df1bbf8f5b [fix](trx-rs): remove wfm denoise and default stereo audio
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 22:01:17 +01:00
sjg d6c89bcc6b [fix](trx-backend-soapysdr): tighten wfm pilot detect and stereo agc
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 21:51:36 +01:00
sjg b722787ada [feat](trx-rs): show live wfm stereo detect state
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 21:44:32 +01:00
sjg 862f0200bb [fix](trx-backend-soapysdr): tune wfm stereo separation trim
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 21:39:35 +01:00
sjg 73fe9d52ff [fix](trx-backend-soapysdr): improve wfm stereo separation
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 21:34:31 +01:00
sjg a456864957 [fix](trx-backend-soapysdr): restore wfm stereo separation
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 21:26:32 +01:00
sjg 1cc60c8a10 [style](trx-frontend-http): align wfm control button sizing
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 20:51:27 +01:00
sjg eb740dbb43 [fix](trx-backend-soapysdr): add missing wfm_denoise arg in dsp tests
Fix pre-existing compilation failures in four test call sites that were
missing the wfm_denoise: bool argument added to ChannelDsp::new() and
SdrPipeline::start().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 20:43:10 +01:00
sjg bbc7e857e5 [fix](trx-backend-soapysdr): improve WFM stereo demodulation quality
- Raise audio LPF cutoff from 15 kHz to 17 kHz to pass full FM stereo
  audio bandwidth without excessive HF rolloff
- Replace 2-point linear interpolation resampler with 4-point Hermite
  cubic spline for a much flatter passband up to 17 kHz
- Add FM discriminator gain normalization (fm_gain = fs / 150000) so
  ±75 kHz deviation maps to ±1.0 regardless of composite sample rate,
  stabilizing stereo carrier amplitude reconstruction
- Double pilot PLL proportional (0.0015→0.003) and integral
  (0.00002→0.00005) gains for faster lock and better tracking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 20:43:01 +01:00
sjg 3d8fd32488 [fix](trx-backend-soapysdr): prevent double agc on mono wfm
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 20:30:12 +01:00
sjg 244f91d97b [fix](trx-backend-soapysdr,trx-frontend-http): make wfm mono mode real
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 20:25:04 +01:00
sjg b7c1da138b [fix](trx-backend-soapysdr): fix WFM stereo separation
The 19 kHz pilot notch was applied only to the L+R sum path, introducing
~22° of phase shift at 15 kHz relative to the L-R diff path.  This phase
mismatch caused interchannel crosstalk (≈ −14 dB separation at 15 kHz).

Fix: remove the notch from the sum processing chain so both sum and diff
pass through identical 4th-order Butterworth LPFs, giving phase-coherent
demodulation across the full audio band.  The notch is relocated to the
mono output branch where phase alignment with the diff channel is not
required.  Pilot rejection on the stereo L/R outputs is still adequate
(~28 dB) from the combined LPF + deemphasis response at 19 kHz.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 20:11:15 +01:00
sjg 95716a0fc3 [feat](trx-rs): expose WFM stereo denoise toggle
Add a server-side toggle for the multiband stereo denoiser so it can be
enabled or disabled at runtime without restarting the server.

Backend (trx-backend-soapysdr):
- Add `denoise_enabled: bool` to `WfmStereoDecoder`; gate multiband
  blend behind it (falls back to uniform single-band blend when off)
- Add `set_denoise_enabled()` method on `WfmStereoDecoder`
- Propagate `wfm_denoise: bool` through `ChannelDsp`, `SdrPipeline`,
  and `SoapySdrRig`; add `set_wfm_denoise()` at each layer
- Include `wfm_denoise` in `filter_state()` so it flows into snapshots

Protocol / core (trx-core, trx-protocol, trx-server):
- Add `SetWfmDenoise(bool)` to `RigCommand` and `ClientCommand`
- Add default `set_wfm_denoise()` trait method to `RigCat`
- Handle `SetWfmDenoise` in `rig_task.rs` and update `RigFilterState`
- Add `wfm_denoise: bool` (default `true`) to `RigFilterState`

Frontend (trx-frontend-http):
- Add `POST /toggle_wfm_denoise` endpoint
- Add "Denoise On/Off" button next to the stereo/mono audio picker
- Sync button state from SSE filter snapshot (`update.filter.wfm_denoise`)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 19:47:36 +01:00
sjg ffdc193671 [feat](trx-backend-soapysdr): add multiband stereo denoising for WFM
Split the L-R diff channel into three frequency bands at audio rate and
apply SNR-weighted blending per band driven by pilot magnitude:

  - 0–2 kHz:   blend¹  (most stereo — low frequencies have best SNR)
  - 2–8 kHz:   blend²  (moderate noise reduction)
  - 8–15 kHz:  blend⁴  (aggressive noise reduction — hiss-prone range)

Move blend from composite rate to audio rate so the crossover filters
(2nd-order Butterworth at 2 kHz and 8 kHz) operate at 48 kHz and the
pilot blend is linearly interpolated per audio sample for smooth
transitions. Unblended diff is now stored in prev_diff; prev_blend
tracks the blend value for the same interpolation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 17:16:46 +01:00
sjg ec1518facc [fix](trx-frontend-http): serve safari-friendly favicon
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 17:14:20 +01:00
sjg e26b8dd6a1 [feat](trx-frontend-http): refresh favicon from logo
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 17:09:51 +01:00
sjg 332ad4448b [fix](trx-backend-soapysdr): fix AM demodulation quality
Three bugs made the AM path sound wrong:

1. AGC attack too fast (5 ms).  The slowest audio a broadcast AM station
   can transmit is ~50 Hz (20 ms period).  A 5 ms attack lets the AGC track
   individual audio cycles, which causes severe pumping and amplitude
   distortion.  Change to 500 ms attack / 5 s release so the AGC only
   responds to slow carrier-amplitude fading, not the audio modulation itself.

2. Bandwidth too narrow.  The IQ filter cutoff is audio_bandwidth_hz / 2,
   so the previous 6 000 Hz setting gave only 3 kHz audio bandwidth.
   AM broadcast sidebands extend to ±4.5–5 kHz; raise the default to
   12 000 (cutoff 6 kHz) to cover the full audio band.

3. DC blocker rate inconsistent.  For AM the demodulated magnitude is
   always ≥ 0 and the DC component equals the carrier amplitude; only true
   DC needs removing.  Unify all non-WFM modes to r = 0.9999 (corner
   ≈ 0.76 Hz @ 48 kHz), which strips carrier DC without touching any
   audible bass content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 17:00:32 +01:00
sjg 1f3bdb988a [fix](trx-frontend-http): avoid rerendering unchanged rds af list
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 16:46:55 +01:00
sjg ce25751c5d [feat](trx-rds,trx-frontend-http): add af tuning and dynamic page title
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 16:41:25 +01:00
sjg a18ef33ee2 [feat](trx-frontend-http): enrich and align rds details
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 16:31:45 +01:00
sjg 1f9c09668d [fix](trx-backend-soapysdr): demodulate CW as USB (real part, not envelope)
CW signals in SDR are centred at an audio offset (e.g. 700 Hz) by the
upstream FIR filter, so demodulating as USB (taking the real part) produces
the correct side-tone.  The previous magnitude/envelope approach produced a
DC pulse per key press with no audible tone.

Re-enable the DC blocker for CW/CWR (r = 0.9999): the output is now audio
that can carry a DC offset from BFO frequency error, identical to USB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 16:25:53 +01:00
sjg eed5673f95 [feat](trx-frontend-http): smooth spectrum waveform
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 16:13:10 +01:00
sjg ba9881ff62 [feat](trx-backend-soapysdr): add AGC and DC blocking to all demod modes
Add SoftAgc — a fast-attack/slow-release envelope AGC with a max-gain cap
— to all demodulated audio paths so that switching between modes (WFM, AM,
SSB, CW, FM) no longer produces large volume jumps.  AGC is applied after
every demodulator, including WFM, with a shared target level of 0.5.

Add per-mode DC blocking (DcBlocker) for USB/LSB/AM/FM/DIG paths to remove
carrier frequency-offset DC from the FM discriminator and LO bleedthrough in
SSB.  CW is excluded because high-passing a non-negative envelope creates
negative-going artifacts on each key release; WFM already has internal DC
blockers on each output channel.

AGC time constants are tuned per mode:
  CW/CWR  – 1 ms attack / 50 ms release  (follows individual dots/dashes)
  AM      – 5 ms attack / 200 ms release  (tracks fading carriers)
  all else– 5 ms attack / 500 ms release  (suits voice and data)

Simplify demod_am and demod_cw: remove per-block peak normalisation and DC
removal that caused block-boundary level discontinuities ("pumping").  Both
now return raw magnitudes and rely on the downstream DC blocker and AGC for
normalisation.

DIG is already wired as Passthrough (identical to USB); no change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 16:04:26 +01:00
sjg 5096e01a60 [feat](trx-client,trx-frontend-http): raise spectrum update rate
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 16:04:15 +01:00
sjg ea6830e343 [feat](trx-frontend-http): add rds raw json copy action
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 15:51:08 +01:00
sjg 6dc26e48a3 [feat](trx-backend-soapysdr): improve WFM audio quality
Replace one-pole sum/diff filters with a proper 4th-order Butterworth
cascade (Q = 0.5412 / 1.3066) at 15 kHz.  This reduces pilot tone
leakage from −4 dB to −12 dB at 19 kHz and suppresses the 38 kHz DSB
carrier from −9 dB to −32 dB, significantly improving stereo crosstalk.

Add a biquad notch at 19 kHz on the L+R channel to eliminate the residual
pilot tone that would otherwise be audible after downsampling to 48 kHz.

Replace nearest-neighbor (sample-hold) resampling with linear interpolation
inside WfmStereoDecoder.  The output sample is now placed at the exact
fractional position between the two adjacent composite samples using the
phase accumulator state, removing timing jitter and harmonic distortion on
sustained tones.

Add DC blockers (pole at 0.9999, corner ≈ 0.75 Hz at 48 kHz) to all audio
outputs to remove carrier frequency-offset DC from the FM discriminator
without any audible bass roll-off.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 15:46:56 +01:00
sjg 42f61de502 [style](trx-frontend-http): tighten overview spacing
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 15:30:02 +01:00
sjg cf8d0743ce [feat](trx-rds,trx-frontend-http): expand rds metadata display
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 15:27:26 +01:00
sjg 8827131264 [fix](trx-frontend-http): copy formatted rds ps text
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 15:11:29 +01:00
sjg b1fab91e0e [fix](trx-frontend-http): shrink rds pi fallback display
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 15:08:53 +01:00
sjg 27c8018d89 [feat](trx-frontend-http): show rds pi before ps arrives
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:55:38 +01:00
sjg b172ace0ee [feat](trx-rds,trx-backend-soapysdr): condition weak-signal rds updates
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:52:31 +01:00
sjg e9fa27be3a [fix](trx-rds): restore prior recovery path
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:46:42 +01:00
sjg 5a27cb634d [feat](trx-backend-soapysdr): prefilter rds subcarrier
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:41:19 +01:00
sjg 2874c63dd1 [fix](trx-rds): relax weak-signal commit thresholds
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:36:46 +01:00
sjg a94fcd75af [feat](trx-rds): add early-late timing correction
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:32:23 +01:00
sjg 017b7be8a8 [fix](trx-rds,trx-backend-soapysdr,trx-frontend-http): relax rds filtering
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:26:11 +01:00
sjg 778e695941 [fix](trx-frontend-http): sanitize stale tune step options
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:21:39 +01:00
sjg 2ed68e4210 [feat](trx-rds,trx-backend-soapysdr): improve weak-signal rds recovery
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:19:36 +01:00
sjg 0329be6124 [fix](trx-frontend-http): clarify jog step controls
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:16:09 +01:00
sjg 8f7afed132 [fix](trx-backend-soapysdr,trx-frontend-http): keep rds state on bw changes
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:13:06 +01:00
sjg 976f66d383 [feat](trx-frontend-http): format copied rds overlay text
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:08:20 +01:00
sjg f48e2a6a81 [feat](trx-frontend-http): improve rds pty and ps display
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:03:46 +01:00
sjg adadcb4a77 [fix](trx-backend-soapysdr,trx-frontend-http): preserve raw rds ps text
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 12:01:27 +01:00
sjg abab89b89c [feat](trx-frontend-http): refine rds overlay interactions
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:58:12 +01:00
sjg 2cd33386ef [fix](trx-frontend-http): add rx audio jitter buffer
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:53:50 +01:00
sjg f509500877 [fix](trx-frontend-http): restore synced frequency and rds badge
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:48:20 +01:00
sjg 4b5dd36778 [feat](trx-server,trx-client): raise default audio opus bitrate
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:33:52 +01:00
sjg b0bf9e3ee0 [feat](trx-rds,trx-frontend-http): reset rds on tune changes
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:31:42 +01:00
sjg e886f97eb9 [fix](trx-backend-soapysdr): back off after stream overruns
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:24:09 +01:00
sjg ee9add1b53 [feat](trx-frontend-http): move map into dedicated tab
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:22:14 +01:00
sjg b131b1d313 [fix](trx-rds): improve biphase symbol decoding
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:20:22 +01:00
sjg ab8a3f4889 [fix](trx-client): remove unused rigctl port local
Drop the now-unused rigctl_port local after removing\nthe shared rigctl listener path.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:11:31 +01:00
sjg 5428d063a7 [fix](trx-client): require per-rig rigctl listeners
Remove the shared rigctl listener path so rigctl only\nruns as per-rig listeners configured through\nfrontends.rigctl.rig_ports.\n\nTighten client config validation to require at least one\nper-rig rigctl port when the rigctl frontend is enabled.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:09:44 +01:00
sjg 6f09022563 [fix](trx-rs): sync sdr rds tuning and deemphasis default
Make the primary SoapySDR DSP channel follow the tuned\nfrequency so RDS decoding stays aligned with the active\nfrequency rather than the hardware center.\n\nMove the default WFM deemphasis setting to server SDR\nconfig and default it to 50 us, then pass that value into\nthe SoapySDR backend.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 11:04:40 +01:00
sjg 855d21fd8a [debug](trx-backend-soapysdr,trx-frontend-http): add RDS diagnostics
Add server-side debug log when RDS data is decoded (PI, PS, PTY).
Extend the RDS panel with active mode, frame counter, and a raw JSON
dump of the last spectrum frame (bins excluded) to help diagnose why
RDS remains absent from the SSE stream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:31:24 +01:00
sjg 88e0be0cdd [feat](trx-frontend-http): mark spectrum peaks
Draw small peak markers on strong visible spectrum maxima\nso snap-tune targets are easier to spot.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:25:56 +01:00
sjg ff8625f04e [style](trx-frontend-http): align auto bw button sizing
Apply the same explicit height and padding rules to the\nAuto BW button as the Set button in the spectrum\ncontrols, including the mobile layout.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:17:47 +01:00
sjg f7367e6270 [feat](trx-frontend-http): add RDS debug panel in Plugins tab
Add an RDS sub-tab to the Plugins panel showing PI code, PS name, PTY
number and name, decoder status, and a raw JSON dump of the latest RDS
data received via SSE. Also list the RDS decoder in the Overview tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:16:15 +01:00
sjg 8b3951d99d [fix](trx-frontend-http): show peak level in tooltip
Include the snapped peak signal level in the spectrum\nhover tooltip alongside the peak frequency.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:11:09 +01:00
sjg d5b1b6f020 [fix](trx-backend-soapysdr): widen WFM IQ filter to preserve RDS subcarrier
The FIR LPF cutoff was audio_bandwidth_hz/2; with wfm_bandwidth_hz=75000
this gave 37.5 kHz, stripping the 57 kHz RDS subcarrier before FM
demodulation. Clamp the IQ filter bandwidth to at least 130 kHz (cutoff
≥ 65 kHz) for WFM so the RDS subcarrier always reaches the decoder,
regardless of the configured audio bandwidth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:10:14 +01:00
sjg e1ab2ae9dc [feat](trx-frontend-http): add automatic bandwidth sizing
Add an Auto BW control that estimates a suitable\nreceive bandwidth from the live spectrum around the\ncurrently tuned peak and applies it to the server.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:09:39 +01:00
sjg 454cf33d6b [fix](trx-frontend-http): sync tuned marker after set_freq
Update the local tuned-frequency state immediately after\nsuccessful set_freq requests so the marker and display stay\nin sync with click-to-tune, manual entry, and jog tuning.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:07:22 +01:00
sjg bfb09213e0 [fix](trx-frontend-http): improve spectrum hover feedback
Show snapped peak frequencies in the spectrum hover tooltip\nand move the bandwidth label to the bottom of the tuned\nfrequency marker.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 09:03:44 +01:00
sjg 3ab8ca8c1e [fix](trx-frontend-http): snap tuning clicks to peaks
Improve spectrum click-to-tune by snapping the selected\nfrequency to a nearby dominant local peak, making signals\neasier to select.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:59:35 +01:00
sjg 3f29ba3db8 [fix](trx-server): inherit global [audio].listen for per-rig audio listeners
In multi-rig mode, each RigInstanceConfig.audio.listen defaulted to
127.0.0.1 independently of the global [audio] listen setting, causing
per-rig audio ports to bind to localhost only and refuse connections
from remote clients.

Fix by passing cli.listen.or(Some(cfg.audio.listen)) as the listen
override, so the global address is always the fallback when --listen
is not supplied on the CLI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:59:15 +01:00
sjg c7b99f6fa9 [fix](trx-frontend-http): hide rig selector when logged out
Keep the top bar visible for unauthenticated users, but\nhide the rig selector until a session is established.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:57:02 +01:00
sjg b27c2f8d73 [feat](trx-frontend-http): adjust auth header and style ids
Keep the top bar visible while logged out, limit access to\nthe Main tab, and leave theme controls available while the\nrig selector stays disabled.\n\nRename the internal style ids from nord/arctic and\nmonokai/lime, including a compatibility remap for saved\nsettings.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:53:51 +01:00
sjg 4b93e8ff97 [feat](trx-frontend-http): rename theme labels
Rename the Nord and Monokai theme labels in the web UI\nto Arctic and Brownie, and update the matching CSS\nsection comments.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:46:10 +01:00
sjg 2f2a74b810 [chore](trx-app): drop per-binary config file support
Only trx-rs.toml with [trx-server] / [trx-client] section headers is
now supported. Simplify ConfigFile trait to a single required method
section_key(); remove config_filename(), combined_key(), and
default_search_paths(). load_from_file() now errors when the expected
section is absent rather than falling back to flat parsing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:45:32 +01:00
sjg 1ee0389de8 [docs](trx-rs): add libsoapysdr dependency note
Update the README system libraries section to list\nlibsoapysdr as a build-time requirement for SoapySDR\nSDR backends.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:44:17 +01:00
sjg b5a1b4af33 [feat](trx-frontend-rigctl): per-rig rigctl listeners for multi-rig setups
Add rig_ports map to RigctlFrontendConfig. When non-empty, one rigctl
TCP listener is spawned per entry instead of the single shared listener,
each routing commands to its assigned rig via rig_id_override on RigRequest.

Add rig_id_override: Option<String> to RigRequest so the remote client
can route individual requests to a specific rig without changing the
globally selected rig. build_envelope prefers the override when set.

Example config:
  [frontends.rigctl]
  enabled = true
  listen = "127.0.0.1"
  port = 4532
  rig_ports.ft817 = 4532
  rig_ports.airspyhf = 4533

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:41:39 +01:00
sjg 2c2a0951a5 [docs](trx-rs): update README config file docs
Document the combined trx-rs.toml search order and clarify\nthe HTTP auth example for combined vs legacy config files.\n\nCo-authored-by: Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:39:55 +01:00
sjg 7a4c2d52b1 [chore](trx-rs): drop legacy ~/.trx-{server,client}.toml search paths
Remove the home-directory dotfile paths that predate the XDG layout.
Search order is now: CWD → ~/.config/trx-rs/ → /etc/trx-rs/, checked
for both the combined trx-rs.toml and the per-binary flat file at each
tier.

Update doc comments and tests accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:31:21 +01:00
sjg 5d43578e03 [chore](trx-rs): replace per-binary example configs with trx-rs.toml.example
Remove trx-server.toml.example and trx-client.toml.example in favour of
a single trx-rs.toml.example using [trx-server] and [trx-client] section
headers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:18:09 +01:00
sjg d8f030667f [chore](trx-rs): remove unused example_toml() methods
Superseded by example_combined_toml() which now covers all use cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:13:00 +01:00
sjg 06312abe42 [feat](trx-app): support combined trx-rs.toml config file
Both trx-server and trx-client now look for a combined trx-rs.toml
with [trx-server] and [trx-client] section headers respectively,
falling back to per-binary config files as before.

Search order per tier: combined trx-rs.toml → flat per-binary file,
checked in CWD, ~/.config/trx-rs/, and /etc/trx-rs/.

--print-config now outputs the config under the appropriate section
header so the combined file can be generated directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 08:08:45 +01:00
sjg 6a47fb00ad [fix](trx-frontend-http,trx-backend-soapysdr): fix audio rate and stereo copy
JS: fix stereo AudioData channel copy — frame.copyTo with planeIndex:0
only fills the left plane; reading it as interleaved data caused every
other sample to be zero, making WFM stereo play at half speed.  Now
calls copyTo per channel with the correct planeIndex.

Rust WFM: replace integer output_decim/output_counter in WfmStereoDecoder
with a fractional phase accumulator (output_phase_inc = audio_rate /
composite_rate).  Integer division caused the effective output rate to
drift from audio_sample_rate when the SDR rate is not an exact multiple
(e.g. 2 MHz SDR → 250 kHz composite → ~50 kHz output instead of 48 kHz,
making audio play 4% slow).

Rust non-WFM: add resample_phase/resample_phase_inc to ChannelDsp and
use a fractional-phase resampler in process_block for non-WFM paths,
ensuring exactly audio_sample_rate samples/sec regardless of SDR rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 07:57:42 +01:00
sjg 7fa4f5d133 [fix](trx-frontend-http): fix jog multiplier reset and rename unit label
Fix updateJogStepSupport to snap jogUnit (not jogStep) to nearest
supported unit, then recompute jogStep = jogUnit * jogMult so the
multiplier is preserved across rig connect/reconnect.

Rename "Mult" label to "Unit Multiplier" for clarity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 02:37:16 +01:00
sjg 4f8658b773 [fix](trx-frontend-http): fix waterfall freeze, RDS bandwidth, add jog multiplier
Fix waterfall overview freezing at steady state by tracking a monotonic
push counter instead of row array length — the array size stays constant
once the waterfall is full, so the previous row-count comparison never
triggered the incremental draw path.

Fix WFM RDS not decoding when switching to WFM from a narrowband mode:
set_mode now resets audio_bandwidth_hz to the mode-appropriate default
(180 kHz for WFM) before rebuilding the FIR, preventing the 57 kHz RDS
subcarrier from being filtered out.

Add 1×/10×/100× multiplier button group next to the jog unit selector.
jogUnit × jogMult gives the effective jog step; both are persisted to
localStorage independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 02:25:39 +01:00
sjg eb3b45da64 [perf](trx-frontend-http): reduce spectrum rate to 5 Hz and cache waterfall offscreen
Lower SPECTRUM_POLL_INTERVAL and SSE tick from 100 ms to 200 ms to halve
the number of spectrum frames pushed to the browser.

Introduce an OffscreenCanvas cache for the overview waterfall: at steady
state only the new row is painted and the existing image is scrolled up,
reducing per-frame work from O(rows × cols) to O(cols).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 02:13:00 +01:00
sjg f65135cb5e [feat](trx-frontend-http): display RDS PS name overlay on overview strip
When a WFM/SDR spectrum stream carries RDS data, show the Program
Service name (8-char station name) centered on the visible waveform
area in DSEG14 monospace — the same font as the frequency display.

- Add #rds-ps-overlay div inside .overview-strip (pointer-events: none,
  z-index: 2, absolutely positioned at the center of the visible canvas)
- updateRdsPsOverlay(rds) shows/hides and updates text on every spectrum
  frame; trims trailing spaces common in RDS PS strings
- Overlay cleared on spectrum disconnect / null frame
- text-shadow uses CSS color-mix against --bg for legibility across all
  style/theme combinations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 02:04:51 +01:00
sjg ff885dd5fd [fix](trx-frontend-http): match center frequency input color to wavelength
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:57:46 +01:00
sjg 51c1d6b912 [fix](trx-frontend-http): style theme toggle button as the opposite theme
When in dark mode the button has a light appearance; when in light mode
it has a dark appearance. This makes the button a preview of what
clicking will switch to, rather than mirroring the current theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:55:21 +01:00
sjg 4930e5cc45 [fix](trx-frontend-http): fix TDZ on lastSpectrumData at style init
Same root cause as the overviewDrawPending TDZ: setStyle() references
lastSpectrumData at module init time but it was declared far below.
Hoist let lastSpectrumData = null to the top variable block and remove
the now-duplicate declaration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:53:08 +01:00
sjg 4f650f5ffb [fix](trx-frontend-http): fix TDZ on overviewDrawPending at style init
setStyle() was called at module init time (to restore the saved style
from localStorage) before the let overviewDrawPending declaration, which
caused a ReferenceError: Cannot access 'overviewDrawPending' before
initialization.

Fix: hoist let overviewDrawPending = false to the top of the variable
declarations block, before any top-level code that may call
scheduleOverviewDraw(). Remove the now-duplicate declaration from its
original location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:51:43 +01:00
sjg 7520796869 [feat](trx-frontend-http): add style picker with Nord, Monokai, Contrast themes
Add a style picker dropdown to the tab bar (right of rig picker) with
four styles — Original, Nord, Monokai, Contrast — each with full
light/dark variants.

CSS: define data-style attribute overrides for all CSS custom properties
(bg, card-bg, borders, text, accents, jog, audio level, filter, spectrum
background) for each of the three new styles × two themes (6 new blocks).

JS: introduce CANVAS_PALETTE lookup table covering spectrum/waveform/
waterfall colors for all style×theme combinations. Add currentStyle(),
canvasPalette(), setStyle() helpers. Persist selection to localStorage.

Replace all isLight ternaries in drawing code with palette lookups:
- drawOverviewWaterfall, drawOverviewSignalHistory, waterfallColor
  signatures changed from isLight flag to pal object
- drawSpectrum uses canvasPalette() for grid lines, labels, fill, line
- spectrumBgColor() now delegates to canvasPalette().bg

Theme toggle also triggers a spectrum redraw so canvas colors update
immediately when switching light/dark.

Also fix light-theme spectrum rendering broken since canvas drawing used
hardcoded dark-only colors (white grid lines invisible on light bg).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:48:45 +01:00
sjg 094d11835c Merge pull request #3 from sgrams/feat/mobile-ux
[feat](trx-frontend-http): improve mobile UX and fix S0/S9 label offset
2026-02-28 01:37:45 +01:00
sjg 4e9516871f [feat](trx-frontend-http): improve mobile UX and fix S0/S9 label offset
- Add viewport meta tag so mobile browsers use device width instead of
  the default 980px desktop viewport
- Add 520px breakpoint: remove controls-tray forced min-width (was
  52–58rem, causing horizontal scroll on phones), stack controls-row
  into a single column with jog wheel first (order: -1)
- Scale frequency DSEG14 display with clamp() on narrow screens
- Make volume sliders responsive width with larger 20px thumb
- Raise spectrum Set/Auto button and input heights to 2.2rem (overrides
  the min-height: 0 that blocked the existing 760px touch-size rule)
- Add overflow-x: auto + flex-shrink: 0 to sub-tab-bar so all six
  plugin tabs are reachable on small screens without clipping
- Swap spectrum hint text based on input device: mouse/keyboard hint
  hidden on touch devices, touch-specific hint shown instead
- Offset S0/S9/S9+ signal history labels 6px below their grid lines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:37:04 +01:00
sjg 449663ddd7 [feat](trx-frontend-http): refine decoder and SDR displays
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:22:30 +01:00
sjg b67fdeef8c [feat](trx-frontend-http): offset header blur panel
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:18:56 +01:00
sjg 01485f3cd4 [fix](trx-frontend-http): rename browser title
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:09:42 +01:00
sjg 7258ea57b2 [fix](trx-frontend-http): restore tabs and relocate peak hold
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:08:56 +01:00
sjg 7d9a00cc4e [fix](trx-frontend-http): move overview scales to the right
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:03:42 +01:00
sjg 4e79820e14 [feat](trx-frontend-http): refine header controls and overlays
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 01:02:51 +01:00
sjg 4c4d14b705 [feat](trx-rs): refine overview strip and logging
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:57:15 +01:00
sjg 8d4a729d7c [fix](trx-frontend-http): keep control tray within width
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:52:12 +01:00
sjg cd045d353c [feat](trx-rs): tighten spectrum refresh and header layout
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:49:55 +01:00
sjg eb920ce138 [fix](trx-frontend-http): clean spectrum stream warning
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:44:56 +01:00
sjg c703a5bafd [perf](trx-rs): optimize spectrum streaming and header overlay
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:43:32 +01:00
sjg adc17507ce [feat](trx-frontend-http): refine top bar and overview controls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:37:37 +01:00
sjg b5cac4be41 [feat](trx-frontend-http): wrap and layer main controls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:34:50 +01:00
sjg b976465e37 [feat](trx-frontend-http): align overview strip with spectrum view
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:31:22 +01:00
sjg cf7b2cb3a5 [fix](trx-frontend-http): restore overview startup order
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:25:13 +01:00
sjg 54c6320f73 [fix](trx-frontend-http): show auth gate before startup checks
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:22:12 +01:00
sjg e422dc0b89 [fix](trx-frontend-http): restore auth-gated startup
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:18:37 +01:00
sjg 258258920d [refactor](trx-frontend-http): align logo header naming
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:14:34 +01:00
sjg d9b73479c7 [fix](trx-frontend-http): remove boxed shell and fix auth gate
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:11:20 +01:00
sjg 2154279d78 [feat](trx-frontend-http): restructure top frontend bars
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:08:34 +01:00
sjg c010073e0c [feat](trx-frontend-http): replace header graph with adaptive strip
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-28 00:04:17 +01:00
sjg fffc4c6b90 [feat](trx-rs): add WFM RDS and playback controls
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:57:46 +01:00
sjg f77d0b0bb1 [perf](trx-frontend-http): smooth SDR spectrum rendering
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:43:48 +01:00
sjg e212283d3c [feat](trx-frontend-http): refine SDR bandwidth control
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:42:51 +01:00
sjg e392e7ffa5 [feat](trx-backend-soapysdr): decode WFM stereo in SDR pipeline
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:40:25 +01:00
sjg 8827b2fa21 [feat](trx-frontend-http): add editable SDR bandwidth control
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:34:51 +01:00
sjg b8c154af60 [feat](trx-frontend-http): add WFM mono and stereo playback switch
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:32:09 +01:00
sjg 14cefd1198 [fix](trx-frontend-http): raise WFM bandwidth cap to 300 kHz
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:30:05 +01:00
sjg c5f1c5308b [fix](trx-server): duplicate mono SDR frames for stereo opus
Signed-off-by: Stan Grams <sjg@haxx.space>
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:29:23 +01:00
sjg dee165008d [fix](trx-frontend-http): hide transmit power controls on RX-only rigs
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 23:21:08 +01:00
sjg 7f222eaf10 [chore](trx-rs): refine pending SDR frontend and backend changes
Signed-off-by: Stan Grams <sjg@haxx.space>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-02-27 23:09:21 +01:00
sjg 071157e5bf [feat](trx-frontend-http): tune SDR spectrum with ctrl-scroll
Signed-off-by: Stan Grams <sjg@haxx.space>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-02-27 23:05:20 +01:00
sjg bc206e8f6b [fix](trx-frontend-http): hide TX audio controls on RX-only rigs
Signed-off-by: Stan Grams <sjg@haxx.space>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-02-27 23:03:46 +01:00
sjg 5730ce2991 [fix](trx-frontend-http): keep spectrum SSE alive on idle ticks
Signed-off-by: Stan Grams <sjg@haxx.space>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-02-27 22:56:34 +01:00
sjg c23f1a4b4d [feat](trx-frontend-http): stream spectrum updates over SSE
Signed-off-by: Stan Grams <sjg@haxx.space>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-02-27 22:53:22 +01:00
sjg 54fb107d3b [fix](trx-rs): keep SDR center frequency stable in-band
Signed-off-by: Stan Grams <sjg@haxx.space>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-02-27 22:48:12 +01:00
sjg 07ad26b54a fix(trx-server): Opus-encode SDR PCM for TCP audio clients
The SDR audio bridge was forwarding PCM to server-side decoders but
never encoding it to Opus for rx_audio_tx, so TCP audio clients
(browser RX button) received nothing. Add Opus encoder initialised
from the rig's audio config and encode each PCM frame alongside the
pcm_tx broadcast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 22:31:40 +01:00
sjg 600830ba3d fix(http-frontend): extend PKT bandwidth max to 25 kHz
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 22:27:44 +01:00
sjg a29264093a feat(http-frontend): BW bookmark on spectrum, dB floor control, remove FIR taps
Replace the BW slider + FIR taps filter panel with a visual bandwidth
bookmark drawn directly on the spectrum canvas:
- Semi-transparent amber gradient strip spanning dialFreq ± BW/2
- Rounded-top bookmark tab at the top of the strip showing the current BW
- Draggable left/right edge handles (cursor: ew-resize) that adjust bandwidth
  live and send set_bandwidth on mouse-up; range clamped per-mode defaults
- Y-axis now labeled with dB values (floor to ceiling) drawn on canvas
- Configurable floor level via number input below spectrum (default -100 dB)
- Auto button fits floor/range to current noise floor and peak level
- Remove FIR taps selector (internal DSP implementation detail)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 22:25:14 +01:00
sjg 146516c52e feat(http-frontend): per-mode bandwidth defaults with live DSP apply
Add MODE_BW_DEFAULTS table mapping each mode to [default, min, max, step]
in Hz. When mode changes (via picker or SSE), applyBwDefaultForMode
updates the filter bandwidth slider range and sends set_bandwidth to the
server, so the DSP filter is rebuilt automatically for the new mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 22:09:35 +01:00
sjg af9f2e092e fix(trx-backend-soapysdr,trx-server): fix hardware tuning and add live filter update
- Compute hardware center as dial - center_offset_hz (was ignoring offset)
- Pass configured bandwidth_hz to device instead of hardcoded 1.5 MHz
- Add retune_cmd channel so set_freq repoints SDR hardware in real time
- Auto-add default channel with mode-appropriate bandwidth when [[sdr.channels]]
  is empty, preventing silent audio with minimal config
- Add ChannelDsp::set_filter to rebuild FIR LPFs at runtime; wire
  set_bandwidth and set_fir_taps to call it so UI filter changes take effect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 22:09:26 +01:00
sjg 547f253837 feat(http-frontend): move spectrum above controls, add zoom/pan/tooltip
Spectrum panel is now placed above the freq/mode/jog row, spanning the
full card width. Key improvements:

- Scroll wheel zooms in/out at the cursor position (up to 64x); double-
  click resets to full bandwidth view.
- Mouse drag pans the visible window; click-to-tune is suppressed when a
  drag has occurred.
- Touch pinch-to-zoom and single-finger drag-to-pan supported.
- Hover tooltip shows the frequency under the cursor, formatted to the
  currently selected unit (MHz/kHz/Hz, matching the jog-step selection).
- Frequency axis labels update to reflect the zoomed visible range.
- Canvas height increased to 160 px; axis bar styled with card bg.
- A small hint line below the panel explains the controls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 21:49:07 +01:00
sjg 952961b9fd feat(trx-client,http-frontend): spectrum waveform with frequency picker
Poll GetSpectrum every 200 ms in remote_client via a dedicated timer that
bypasses the main state-watch channel (no SSE noise). The resulting
SpectrumData is stored in FrontendRuntimeContext::spectrum and served by
a new GET /spectrum endpoint (JSON or 204 when unavailable).

HTTP frontend shows a spectrum panel (canvas + frequency axis) only when
the rig reports filter_controls=true (i.e. SoapySDR). The canvas renders:
- dark background with dBFS grid lines
- green FFT spectrum line with semi-transparent fill
- red dashed vertical marker at the currently tuned frequency
- frequency axis labels (MHz/kHz) below the canvas

Clicking the canvas tunes the rig to the clicked frequency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 21:36:05 +01:00
sjg 76969b5499 feat(trx-core,trx-protocol,trx-backend-soapysdr): add spectrum data pipeline
Add SpectrumData struct (bins, center_hz, sample_rate) to RigState and
RigSnapshot. Add GetSpectrum RigCommand and ClientCommand plumbed through
the protocol layer. SoapySDR DSP pipeline now computes a 1024-bin FFT
(Hann window, FFT-shifted, dBFS) every 4 IQ blocks (~10 Hz update rate)
and exposes it via RigCat::get_spectrum(). The rig_task handles
GetSpectrum without persisting spectrum data in ongoing state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 21:35:53 +01:00
sjg df79f06ff0 fix(trx-backend-soapysdr): implement real IQ streaming and fix PKT demodulation
Three root causes prevented APRS decoding at 144.800 MHz with PKT/FM mode:

1. `RealIqSource::read_into` returned zeros — the SoapySDR streaming API
   was never wired up.  `RxStream<Complex<f32>>` is `Send` and
   `StreamSample` is implemented for `num_complex::Complex<f32>` in the
   soapysdr 0.3 crate, so the stream can read directly into the IQ buffer.
   Now creates and activates an `RxStream` in `new()` and calls
   `stream.read` in `read_into`.

2. PKT mode used `Passthrough` (take `.re`) demodulation.  VHF/UHF packet
   radio (APRS, AX.25) is FM-encoded AFSK — it must be FM-demodulated
   before the APRS decoder sees the audio tones.  Changed PKT to `Fm`.

3. `iq_read_loop` always slept `block_duration_ms` after each read.  Real
   hardware already blocks inside `read_into`; the extra sleep doubled
   latency.  Added `IqSource::is_blocking()` (default `false`; `true` for
   `RealIqSource`) and skip the throttle sleep for blocking sources.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 01:25:34 +01:00
sjg 1a3cc0def7 style(http-frontend): make rig subtitle bold 2026-02-27 01:22:01 +01:00
sjg ca097efb33 feat(http-frontend): link header callsigns to qrzcq 2026-02-27 01:21:41 +01:00
sjg d4cb390721 fix(http-frontend): use configured rig display name in header 2026-02-27 01:19:22 +01:00
sjg 0a7195c93c fix(trx-server): add shutdown signal to audio capture/playback threads
Pass `watch::Receiver<bool>` into `run_capture` and `run_playback` so
both threads check for shutdown at the top of their outer recovery loop
and inner monitoring loop. Without this, restarting the process (e.g.
via systemd after an ALSA error) left the old threads stalled forever
while new ones were created alongside them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 01:15:57 +01:00
sjg 223c01392a feat(http-frontend): add rig and owner header lines 2026-02-27 01:14:30 +01:00
sjg 600257a7c4 [perf](trx-backend-soapysdr): replace FIR with FFT overlap-save via rustfft
Replace the per-sample ring-buffer FIR convolution with block-level
overlap-save convolution using rustfft. For a block of M samples and
N taps the old approach costs O(N·M); the new one costs O(M log M),
with rustfft using SIMD (AVX2/SSE4) internally.

Key changes:
- Add rustfft = "6" dependency
- Add BlockFirFilter: overlap-save filter with pre-computed H(f) and
  a single forward+inverse FFT pair per block (no per-sample multiply)
- ChannelDsp.process_block() now:
  1. Batch-mixes entire block to baseband in one vectorisable loop
  2. Applies BlockFirFilter to I and Q (one FFT pair each)
  3. Decimates and demodulates as before
- Keep the old FirFilter for unit tests (sample-by-sample interface)
- Add BlockFirFilter unit tests (DC passthrough, length preservation)
- IQ_BLOCK_SIZE promoted to pub const for use in filter sizing

For the default config (4096-sample blocks, 64 taps, decim=40):
  Old: ~262144 multiply-adds per FIR × 2 components = ~524k per block
  New: ~2 × (3 × 8192 × log2(8192)) ops, all SIMD-vectorised by rustfft

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 01:05:49 +01:00
sjg 0cfbf22c18 fix(http-frontend): prevent rig switch picker population crash 2026-02-27 00:59:13 +01:00
sjg 6c213369da [fix](trx-ft8): fix const qualifier warning in ft8_lib C code
Fix compiler warning about discarded const qualifier in strchr() call.
The result of strchr(const char*, ...) should be assigned to const char*,
not char*.

This resolves:
  warning: initialization discards 'const' qualifier from pointer target type

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 00:42:29 +01:00
sjg bc270834e0 [chore](trx-rs): enable CPU optimizations for better performance
Add .cargo/config.toml to enable native CPU features (AVX2, SSE4.2, etc.)
for maximum performance during compilation. This allows rustc and
dependencies to use SIMD instructions and other CPU optimizations.

This should reduce CPU usage of the DSP backend by allowing:
- Vectorized floating-point operations
- Better compiler optimizations for complex number math
- SIMD acceleration in dependencies like num-complex

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 00:36:37 +01:00
sjg 2bdc23400a [fix](trx-frontend-http): hide rig picker when auth is required
Hide the header rig picker when showing the authentication gate,
since no rigs are accessible until the user authenticates.
Show the picker again when auth gate is hidden (after login).

This improves UX by not showing an empty picker to unauthenticated users.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 00:26:57 +01:00
sjg efd2c53754 [fix](trx-server): auto-generate rig IDs when not specified in config
When rigs are configured without explicit IDs in TOML, they now
receive auto-generated IDs based on model name and index (e.g.,
"ft817_0", "soapysdr_1"). Previously, the default empty ID prevented
auto-generation from triggering.

Changes:
- Set RigInstanceConfig default id to empty string (was "default")
- This allows resolved_rigs() auto-generation to trigger for all rigs
- Legacy single-rig path still gets explicit "default" ID
- Auto-generated IDs now appear in HTTP API /rigs endpoint
- Rig picker now displays auto-IDs correctly

The fix ensures that rigs without explicit ids get proper identifiers
that appear in the protocol and frontend selectors.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 00:24:15 +01:00
sjg a370742d2b [chore](trx-rs): remove test config file
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 00:19:05 +01:00
sjg 12d967154c [fix](trx-backend-soapysdr): improve device initialization diagnostics
Enhance error handling and messaging for SoapySDR device initialization:
- Add fallback logic to try empty args if device-specific args fail
- Provide detailed multi-line error messages with troubleshooting guidance
- Include suggestions to check SoapySDR plugins and run SoapySDRUtil --probe
- Clarify device lifetime management in struct
- Document why actual streaming not yet implemented (soapysdr 0.3 limitations)
- Note that sdr 0.3 requires FFI or upgrade for streaming support

This helps users diagnose device initialization failures and understand
the current architectural limitations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-27 00:18:07 +01:00
sjg 6ed3f96155 [refactor](trx-server): access soapysdr through trx-backend facade
Remove direct dependency on trx-backend-soapysdr from server Cargo.toml.
Re-export SoapySdrRig from trx-backend instead so server accesses it
through the proper facade. This keeps sub-backends internal to trx-backend
and maintains proper module organization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:12:38 +01:00
sjg c3ea605924 [refactor](trx-backend-soapysdr): remove feature gating, require real hardware
Drop optional feature gating - SoapySDR hardware support is now required.
Make soapysdr a required dependency in Cargo.toml instead of optional.
Update server to always enable soapysdr backend and its dependencies.
Simplify initialization to always use RealIqSource instead of conditional
fallback to MockIqSource.

This assumes SoapySDR library is installed on the system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 00:09:10 +01:00
sjg 1de15cba7e [feat](trx-server): auto-generate rig IDs from model name if not specified
Allow rigs to have empty IDs in config; auto-generate from backend model
name with numeric suffix (e.g., 'ft817_0', 'ft817_1', 'soapysdr_0').
This makes config more concise when using multiple instances of the same
model without explicit ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:59:49 +01:00
sjg 5a6cac470a [feat](trx-backend-soapysdr): implement real hardware IQ source with feature gating
Add RealIqSource that connects to actual SoapySDR devices when soapysdr-sys
feature is enabled. Implements proper device initialization, frequency/bandwidth
configuration, gain control, and RX stream management.

When soapysdr-sys feature is disabled (default), falls back to MockIqSource
for testing. Update feature flags in Cargo.toml dependencies to support both
real hardware and mock operation.

- Device initialization with proper error handling
- RX frequency, bandwidth, and gain configuration
- IQ sample streaming via broadcast channel
- Proper resource cleanup via Drop trait
- Throttled MockIqSource to prevent 100% CPU

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:58:12 +01:00
sjg a337c0ccea [fix](trx-backend-soapysdr): throttle MockIqSource to avoid 100% CPU load
Add sleep proportional to block duration in iq_read_loop to simulate
real hardware timing. MockIqSource immediately returns samples without
any delay, causing busy-looping. Throttling prevents excessive CPU usage
when using the mock source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:48:41 +01:00
sjg 2fe3ee6fc1 [feat](trx-frontend-http): remove rig selector from about page
Use header rig selector as the single point for rig switching. Remove
redundant about page rig selector controls and associated JavaScript
event listeners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:46:12 +01:00
sjg f716e7ec47 [feat](trx-client): support rig display names in frontend layer
Add display_name field to RemoteRigEntry and propagate from GetRigs
response. Update http-json frontend to include display_name in rig
entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:46:08 +01:00
sjg 283c9b049e [feat](trx-protocol): add display_name to RigEntry
Include optional display_name field in GetRigs response to allow clients
to show friendly rig names instead of just identifiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:46:05 +01:00
sjg 8297af8302 [feat](trx-server): add configurable display name for rigs
Add optional `name` field to RigInstanceConfig to allow long-form rig names
(e.g., "HF Transceiver"). The name defaults to rig id if not configured.
Add display_name() method to get display name with fallback to id.
Propagate display_name through RigHandle to listener for GetRigs response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:46:02 +01:00
sjg 68944f7d78 [fix](trx-backend): fix clippy warnings and improve code style
Fix useless vec! allocation in demod tests, improve loop iterator usage,
and reformat code for better readability across dummy and soapysdr
backends.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:33:40 +01:00
sjg f8a683b312 [style](trx-server): improve code formatting and remove unused imports
Reformat assertions, multi-line function calls, and error handling for
better readability. Remove unused soapysdr feature import in main.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:33:37 +01:00
sjg 30ac7aab1e [feat](trx-server): add soapysdr as default feature
Enable SoapySDR backend by default to make multi-device setups work out
of the box without requiring explicit feature flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:33:34 +01:00
sjg 68ca798b47 [style](trx-client): improve audio and HTTP frontend code formatting
Reformat multi-line statements and function calls for better readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:33:30 +01:00
sjg e3626eafd4 [style](trx-core): improve code formatting and trait readability
Reformat filter state methods and error handling for better consistency.
Improve readability of complex return type expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:33:27 +01:00
sjg 3cd614e6a2 [style](trx-protocol): reformat codec tests for readability
Improve assertion and struct literal formatting across test cases
for better code clarity and consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:33:24 +01:00
sjg b7073e9104 fix(trx-client): increase command timeouts for slow CAT responses 2026-02-25 23:40:56 +01:00
sjg 47cff3f927 feat(multi-rig): auto-discover per-rig audio ports via GetRigs 2026-02-25 23:31:38 +01:00
sjg b1c232f388 fix(trx-client): route audio by selected rig with per-rig port map 2026-02-25 23:23:48 +01:00
sjg 8ffeba47df fix(http-ui): avoid blocking controls on slow rig initialization 2026-02-25 23:20:21 +01:00
sjg a052c8a33d fix(trx-client): populate rig picker eagerly and via /rigs refresh 2026-02-25 23:17:05 +01:00
sjg bd80ffa870 fix(trx-client): prefer TX rig default and add header rig picker 2026-02-25 23:14:05 +01:00
sjg 6f836a93e9 feat(trx-client): add runtime multi-rig discovery and switching 2026-02-25 22:39:02 +01:00
sjg 30ad6d1bb4 feat(trx-client): add remote rig_id selection via config and CLI 2026-02-25 22:31:59 +01:00
sjg 49118b65a1 [chore](trx-rs): remove implementation stub files
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 21:36:34 +01:00
sjg 93536ed45e [docs](trx-rs): add SKILLS.md documenting project slash commands
Documents the /frontend-design skill (project-scoped, in
.claude/commands/frontend-design.md) and explains how to add new
skills. Lists global skills available across all projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 20:33:51 +01:00
sjg 08acbeecad [chore](trx-rs): add .claude/ to .gitignore
Prevent Claude Code session files, worktrees, and settings from
being accidentally committed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 20:25:52 +01:00
sjg 66163c7e7d [feat](trx-client): capability-gated UI controls and filter panel (UC-05..07)
Add /set_bandwidth and /set_fir_taps HTTP endpoints to api.rs.

Add applyCapabilities(caps) function to app.js that shows/hides:
- PTT button and TX meters: capabilities.tx
- TX limit row: capabilities.tx_limit
- VFO row: capabilities.vfo_switch
- Signal meter row: capabilities.signal_meter
- Filters panel: capabilities.filter_controls

Called from render() whenever capabilities are present; runs on both
initial /status response and every SSE event.

Add a Filters panel to index.html with bandwidth slider (1..500 kHz)
and FIR taps select (16/32/64/128/256); hidden by default, revealed by
applyCapabilities when filter_controls is set. Each control dispatches
to the corresponding HTTP endpoint on change.

Sync filter state from update.filter in render() to keep slider/select
in sync with server-side DSP state.

Fix missing struct fields in test helpers across remote_client.rs,
trx-frontend-http-json/server.rs, trx-frontend-rigctl/server.rs, and
trx-core controller tests (handlers.rs, machine.rs).

Update aidocs/UI-CAPS.md: all tasks UC-01..UC-09 marked [x].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 20:25:11 +01:00
sjg 3b98c8b7b5 [feat](trx-server): dispatch SetBandwidth and SetFirTaps in rig_task
Handle SetBandwidth(hz) and SetFirTaps(taps) in process_command:
call the RigCat methods, update filter state in-place, broadcast
the updated state, and return the new snapshot.

Fix missing capability fields in listener.rs test helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 20:25:00 +01:00
sjg dc33d1e841 [feat](trx-backend): set capability flags; add SDR filter state; add UC-08 tests
Set tx/tx_limit/vfo_switch/filter_controls/signal_meter on all backends:
- FT-817, FT-450D, dummy: tx=true, tx_limit=true, vfo_switch=true,
  filter_controls=false, signal_meter=true
- SoapySDR: tx=false, tx_limit=false, vfo_switch=false,
  filter_controls=true, signal_meter=true

SoapySDR backend now stores bandwidth_hz and fir_taps fields; overrides
set_bandwidth, set_fir_taps, and filter_state on RigCat to expose live
DSP state in the snapshot.

Add UC-08 unit tests on dummy backend asserting tx capabilities present
and filter_controls absent, and that filter_state returns None.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 20:24:55 +01:00
sjg 3335942374 [feat](trx-protocol): add SetBandwidth, SetFirTaps commands; add UC-09 tests
Add SetBandwidth { bandwidth_hz: u32 } and SetFirTaps { taps: u32 } to
ClientCommand with bidirectional mapping to RigCommand variants.

Add UC-09 protocol serialization tests confirming that RigSnapshot
serializes the filter field when Some and omits it when None.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 20:24:48 +01:00
sjg 9177206dd9 [feat](trx-core): add capability flags and filter state for UI gating
Add five new boolean fields to RigCapabilities: tx, tx_limit,
vfo_switch, filter_controls, signal_meter. These drive which controls
the HTTP frontend shows or hides per rig type.

Add RigFilterState struct (bandwidth_hz, fir_taps, cw_center_hz) and
filter: Option<RigFilterState> to both RigState (skip-serialized) and
RigSnapshot (skip_serializing_if = None).

Add SetBandwidth and SetFirTaps to RigCommand; add default not-supported
implementations of set_bandwidth, set_fir_taps, and filter_state to
the RigCat trait.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 20:24:43 +01:00
sjg 80afb928ae [chore](trx-rs): rename autogendoc/ to aidocs/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 19:21:43 +01:00
sjg eec5aaf811 [chore](trx-rs): move autogenerated spec docs into autogendoc/
Keeps README.md, CLAUDE.md, and CONTRIBUTING.md at root as standard
project files. Moves AI-generated design/specification documents
(AGENTS, AUTH, CONFIGURATION, ENHANCEMENT, MULTI, OVERVIEW, SDR,
UI-CAPS) into autogendoc/ to distinguish them from hand-maintained docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 08:11:38 +01:00
sjg d7d2b3f790 [docs](trx-rs): add UI-CAPS.md plan for capability-gated UI controls
Specifies UC-01 through UC-09: extending RigCapabilities with tx/tx_limit/
vfo_switch/filter_controls/signal_meter flags, RigFilterState struct,
SetBandwidth/SetFirTaps protocol commands, new HTTP endpoints, and
frontend visibility gating via applyCapabilities() in app.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 08:11:28 +01:00
sjg 758dde97a2 [docs](trx-rs): add MULTI.md progress tracking for multi-rig support
Documents architecture, TOML format, protocol wire format, validation
rules, and task completion status (MR-01 through MR-09).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 08:04:23 +01:00
sjg 0b8c408c17 [feat](trx-server): implement multi-rig support
Enable N simultaneous rig backends in one server process:

- rig_handle.rs: new RigHandle { rig_id, rig_tx, state_rx } thin struct
- config.rs: RigInstanceConfig, rigs: Vec<RigInstanceConfig> in ServerConfig,
  resolved_rigs() (synthesises legacy flat fields as id="default"),
  validate() checks unique rig IDs and audio ports; MR-08 config tests
- audio.rs: replace four OnceLock<Mutex<VecDeque>> statics with
  DecoderHistories { aprs, ft8, wspr } Arc struct; decoder/listener
  functions now take Arc<DecoderHistories> for per-rig isolation
- rig_task.rs: add rig_id + histories: Arc<DecoderHistories> to
  RigTaskConfig; clear_*_history calls use ctx.histories instance methods
- listener.rs: run_listener takes Arc<HashMap<String, RigHandle>> +
  default_rig_id; routes envelope.rig_id to correct rig; GetRigs fast
  path aggregates all rig states; all responses include rig_id field
- main.rs: loop over resolved_rigs(); spawn_rig_audio_stack() helper;
  builds Arc<HashMap<String, RigHandle>> passed to run_listener

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 08:04:19 +01:00
sjg bb3464e32f [feat](trx-protocol): add multi-rig protocol support
Add GetRigs command, rig_id routing fields, and RigEntry type:
- ClientCommand::GetRigs (intercepted in listener before rig_task)
- rig_id: Option<String> on ClientEnvelope (absent = first rig)
- rig_id: Option<String> and rigs: Option<Vec<RigEntry>> on ClientResponse
- RigEntry { rig_id, state } for GetRigs aggregated response
- Sentinel unreachable!() arm for GetRigs in client_command_to_rig()
- MR-09 codec tests: rig_id parsing, GetRigs round-trip, serde omit-when-None

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-25 08:04:08 +01:00
sjg 319e935d97 [feat](trx-server): wire SoapySDR backend and AudioSource into server startup
- Call validate_sdr() at startup and abort on errors (SDR.md §11)
- Build SoapySdrRig with full [[sdr.channels]] config when access_type is
  "sdr"; subscribe to its primary-channel PCM sender before handing the
  pre-built rig to the rig task via RigTaskConfig.prebuilt_rig
- Skip cpal capture when an SDR audio source is available; bridge the
  SdrPipeline PCM broadcast into pcm_tx so all decoder tasks are unchanged
- Add trx-backend-soapysdr optional dep and soapysdr feature to trx-server
- Add prebuilt_rig field to RigTaskConfig so rig_task skips the registry
  factory when a pre-built rig is supplied by main

Marks SDR-08 complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 22:27:15 +01:00
sjg dc3fd63a83 [test](trx-server): add SDR config validation unit tests
Adds 12 unit tests for ServerConfig::validate_sdr() covering: minimal
valid config, non-SDR skip, empty/missing args, zero sample_rate,
channel IF out-of-range (positive, negative, exactly at Nyquist), dual
stream_opus, tx_enabled with SDR backend, duplicate decoder, and
multiple simultaneous errors. Marks SDR-11 complete in SDR.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 22:12:27 +01:00
sjg 1be5b3d4c2 [feat](trx-backend-soapysdr): implement SoapySdrRig with AudioSource and DSP wiring
Replace the stub SoapySdrRig with a full implementation: wire up SdrPipeline
from dsp.rs, implement AudioSource::subscribe_pcm on the primary channel,
add gain control (manual/auto with fallback warning), and track primary
channel freq/mode so set_freq/set_mode update the live DSP pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 21:57:39 +01:00
sjg 1353e2a29b [test](trx-backend-soapysdr): add demodulator unit tests
Add 9 unit tests covering all demodulators in demod.rs: USB/LSB/Passthrough
real-part extraction, AM DC removal and varying-envelope, FM tone frequency
and silence, CW peak normalisation, mode mapping, and empty-input safety.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 21:51:48 +01:00
sjg 5090ab71b3 [feat](trx-backend-soapysdr): implement IQ DSP pipeline (mixer/FIR/decimate/demod)
Add dsp.rs with IqSource trait abstraction, MockIqSource, windowed-sinc
FIR low-pass filter, ChannelDsp (mixer/decimate/demod/frame-accumulator),
and SdrPipeline which spawns a dedicated IQ read thread. No soapysdr crate
dependency; the real device will be wired in SDR-07.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 21:34:09 +01:00
sjg db6dff304e [feat](trx-backend-soapysdr): implement demodulators (USB/LSB/AM/FM/CW)
Add demod.rs with Demodulator enum and per-mode demodulate() implementations:
USB/Passthrough (real part), LSB (real part, IF negated upstream), AM
(envelope, DC removal, peak normalisation), FM/WFM (quadrature discriminator
scaled by 1/π), CW (magnitude envelope, peak normalised). Marks SDR-05 done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 21:29:27 +01:00
sjg 04f8fd019a [docs](trx-rs): document SDR backend configuration options
Add [sdr], [sdr.gain], and [[sdr.channels]] sections to CONFIGURATION.md,
extend [rig.access] with type = "sdr" / args, add CLI override note, and
append a commented SoapySDR example block to trx-server.toml.example.
Mark SDR-09 as complete in SDR.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 21:24:41 +01:00
sjg 2ee217a1a1 [feat](trx-backend-soapysdr): add crate scaffold with RigCat stub
Introduces the trx-backend-soapysdr crate with a compilable SoapySdrRig
struct that satisfies the Rig + RigCat trait bounds.  RX methods
(get_status, set_freq, set_mode, get_signal_strength) are implemented;
TX-only methods return RigError::not_supported.  as_audio_source returns
None for now (overridden in SDR-07).  Wires the crate into the workspace
and trx-backend (feature "soapysdr"), and fixes the non-exhaustive match
on RigAccess::Sdr in trx-server main.rs and rig_task.rs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 19:51:52 +01:00
sjg b00f054bd4 [feat](trx-server): add SdrConfig structs and SDR access config validation
Add SdrConfig, SdrGainConfig, and SdrChannelConfig structs to config.rs,
extend AccessConfig with an args field for the "sdr" access type, wire
SdrConfig into ServerConfig, and implement validate_sdr() with all
startup validation rules from SDR.md §11. Marks SDR-03 complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 19:16:27 +01:00
sjg b615b68e40 [feat](trx-core): add AudioSource trait and RigCat::as_audio_source
Add AudioSource trait to trx-core rig module providing subscribe_pcm()
for demodulated PCM audio. Add opt-in as_audio_source() default method
to RigCat returning None; SDR backends will override to return Some(self).
Re-export AudioSource from the crate root. Marks SDR-01 complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 19:09:04 +01:00
sjg bf19ffb38d [feat](trx-backend): add RigAccess::Sdr variant and soapysdr feature stub
Add `RigAccess::Sdr { args: String }` to the backend access enum, register
a feature-gated `soapysdr` factory stub in `register_builtin_backends_on`,
handle the new variant in all existing `match` arms, and add the `soapysdr`
feature flag (no dep yet; implementation lands in SDR-04). Mark SDR-02 done.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 19:07:00 +01:00
sjg 060454780f [docs](trx-rs): add CLAUDE.md for Claude Code guidance
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-02-24 18:05:59 +01:00
sjg b9005acffd [refactor](decoders): extract decoder logging into trx-decode-log crate
Move DecoderLoggers and DecodeLogsConfig out of trx-server into a
dedicated src/decoders/trx-decode-log crate, giving file logging the
same standalone crate treatment as the four decoder crates.

- src/decoders/trx-decode-log/ (new — DecodeLogsConfig + DecoderLoggers)
- trx-server/config.rs: re-exports DecodeLogsConfig from trx-decode-log
  so ServerConfig field references and all tests compile unchanged
- trx-server: drop decode_logs module, use trx_decode_log directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 18:36:04 +01:00
sjg f4b92a0f20 [refactor](decoders): extract CW decoder into trx-cw crate
Move the Goertzel-based CW decoder out of trx-server::decode::cw into
a dedicated src/decoders/trx-cw crate, matching the layout of trx-aprs,
trx-ft8, and trx-wspr. The decode module is now empty and removed.

- src/decoders/trx-cw/ (new — Goertzel + Morse decoder)
- trx-server: drop decode module entirely, use trx_cw::CwDecoder
- CwEvent stays in trx-core (mirrors AprsPacket / Ft8Message / WsprMessage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 18:33:02 +01:00
sjg 3ebd185a7e [refactor](decoders): consolidate decoder crates under src/decoders/
Move trx-ft8 and trx-wspr into src/decoders/ alongside a new trx-aprs
crate that extracts the Bell 202/AX.25 decoder from trx-server, giving
all three modems a consistent crate-per-decoder layout.

- src/decoders/trx-ft8/  (moved from src/trx-ft8/)
- src/decoders/trx-wspr/ (moved from src/trx-wspr/)
- src/decoders/trx-aprs/ (new — Bell 202 AFSK + AX.25/APRS decoder)
- trx-ft8/build.rs: fix external/ft8_lib relative path after move
- trx-server: drop decode::aprs module, use trx_aprs::AprsDecoder
- AprsPacket stays in trx-core (mirrors Ft8Message / WsprMessage)
- Workspace Cargo.toml updated with new member paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 18:30:04 +01:00
sjg 0d6a35a933 [feat](trx-server): add APRS-IS IGate uplink (aprs.fi integration)
Forwards CRC-valid RF APRS packets to APRS-IS via plain TCP using the
TNC2 line format, making them visible on aprs.fi and other APRS-IS
consumers. Mirrors the pskreporter module in structure.

- New aprsfi.rs: IGate task with reconnect loop (exponential backoff
  1s→60s), login/logresp, 60s keepalive, 60s stats, passcode
  auto-computation from callsign (standard APRS hash algorithm)
- config.rs: AprsFiConfig struct with enabled/host/port/passcode fields
  and validation; default host rotate.aprs.net:14580
- main.rs: mod aprsfi; spawn task inside audio block when aprsfi.enabled
- trx-server.toml.example, CONFIGURATION.md: document [aprsfi] section
- Remove APRSFI_IMPLEMENTATION.rs planning artifact

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 18:23:35 +01:00
sjg e12a3dfa4f [fix](trx-server): re-enumerate audio device on each stream recovery cycle
After ALSA POLLERR the existing cpal Device handle can be stale, causing
repeated stream build failures with no path to recovery. Re-acquire host
and device inside the outer recreation loop so each attempt gets a fresh
handle. Device-not-found is now a warn+retry rather than a fatal error,
allowing recovery from transient USB audio device disappearances.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 15:59:29 +01:00
sjg 1a282f5ec6 [fix](trx-server): prevent audio thread crash on ALSA EPIPE
CPAL error callbacks can fire millions of times per second on ALSA
EPIPE (errno -32). Previously each invocation did a string allocation
and mutex lock, saturating CPU and eventually crashing the server.

- Use atomic swap in both input and output error callbacks so only the
  first error fires the expensive log+notify path; all subsequent hits
  cost a single atomic op and return immediately.
- Replace stream.play()? with explicit error handling in run_playback
  so a play failure triggers stream recreation instead of permanently
  terminating the playback thread.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <stanislawgrams@gmail.com>
2026-02-23 13:24:55 +01:00
sjg bbe37c4fd2 [test](trx-client): update port assertions for 4530/4531 defaults
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <stanislawgrams@gmail.com>
2026-02-23 12:39:52 +01:00
sjg 66baa73fa9 [test](trx-server): update port assertions for 4530/4531 defaults
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <stanislawgrams@gmail.com>
2026-02-23 12:39:49 +01:00
sjg baf9540071 [docs](trx-rs): update default port references to 4530/4531
Reflect new defaults: client-server 4530, audio 4531, rigctl 4532.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <stanislawgrams@gmail.com>
2026-02-23 12:12:54 +01:00
sjg 5ee5b32d64 [chore](trx-client): update default ports to 4530/4531
Change default remote connection port from 4532 to 4530 and audio
server_port from 4533 to 4531.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <stanislawgrams@gmail.com>
2026-02-23 12:12:49 +01:00
sjg 5dc2b923d1 [chore](trx-server): update default ports to 4530/4531
Change JSON TCP listener default from 4532 to 4530 and audio
streaming default from 4533 to 4531.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <stanislawgrams@gmail.com>
2026-02-23 12:12:04 +01:00
sjg fa26d154b1 [docs](trx-rs): clean up README description and remove WIP notice
Simplify the opening paragraph for better readability and remove the
(work in progress) notice since the project has substantial, documented
features and a stable structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <sjg@haxx.space>
2026-02-20 23:05:20 +01:00
sjg 73e57d1cf1 [feat](trx-frontend-http): add security headers and real IP logging
Add CORS, referrer policy, and content-type security headers.
Configure logger to track real client IP in reverse-proxy setups
via Forwarded / X-Forwarded-For / X-Real-IP headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <sjg@haxx.space>
2026-02-20 23:04:29 +01:00
sjg 0de1d1bd6b [fix](trx-frontend-http): restore tabs and retune dark theme
Fix authenticated refresh by restoring tab visibility during startup, and retune dark mode toward deep blue with amber-red accents.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 20:31:12 +01:00
sjg 1d0db79ca3 [feat](trx-rs): show server/client build info in HTTP footer
Add compile-time build dates for trx-server and trx-frontend-http, propagate server build metadata through rig state/snapshot, and render both versions + build dates in the HTTP footer.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 11:06:13 +01:00
sjg ae40e5cce3 [style](trx-frontend-http): align unit and power labels to the right
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 10:59:15 +01:00
sjg 984dfc09ec [style](trx-frontend-http): align unit and power labels right
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 10:47:10 +01:00
sjg 1b97bc9795 [feat](trx-rs): harden auth UI and extend planning docs
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 10:44:10 +01:00
sjg 8a4e9f5ed3 [feat](trx-server): add optional per-decoder log files
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 10:43:52 +01:00
sjg 3f9f515296 [feat](trx-frontend-http): allow signal clear button for rx role
Enable sig-clear-btn for RX users since clearing signal measurements
is a local UI operation that doesn't affect rig state.

Only disable decode history clear buttons:
- aprs-clear-btn
- ft8-clear-btn
- wspr-clear-btn
- cw-clear-btn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 09:07:05 +01:00
sjg 3510a7910b [style](trx-frontend-http): create connected button group for theme/auth buttons
Replace separate theme and auth buttons with a visually connected
button group. Buttons are grouped with no gap between them and
rounded corners only on the outer edges, creating a cohesive control.

Features:
- First button: rounded left edges
- Last button: rounded right edges
- Middle buttons: sharp edges (if more added)
- Negative margin to overlap borders smoothly
- Hover effect for feedback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 09:05:58 +01:00
sjg 0f9f00c441 [fix](trx-frontend-http): show Main tab by default after login
After logging in, automatically show the Main tab-panel and mark
the Main tab button as active. Previously, users had to click the
Main tab to see content after login.

hideAuthGate() now:
- Shows the Main tab-panel
- Hides all other tab-panels
- Marks the Main tab button as active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:58:10 +01:00
sjg d49b379481 [fix](trx-frontend-http): hide all tab panels when showing auth gate
When logout is clicked from the About tab, hide all tab panels to
prevent tab content from showing behind the login window.

Also ensures auth gate is displayed cleanly without any stray content
visible behind it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:56:29 +01:00
sjg 4c7b89b4e3 [feat](trx-frontend-http): disable clear buttons for rx role
Disable all decode history clear buttons for rx-authenticated users:
- APRS clear
- FT8 clear
- WSPR clear
- CW clear
- Signal clear

These are control operations that should be restricted to full access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:55:48 +01:00
sjg d5cd2c6204 [fix](trx-frontend-http): show guest button after logout when available
After logout, check if guest mode is available and show the
"Continue as Guest" button if no rx_passphrase is configured.

Previously, authLogout() always passed false to showAuthGate(),
hiding the guest button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:54:26 +01:00
sjg 82009494b5 [fix](trx-frontend-http): return 403 for unrestricted RX users on control endpoints
When rx_passphrase is not set, RX users have an implicit role without
a session. They should get 403 on control endpoints, not 401.

Previously, unrestricted RX users (with no session) trying control
endpoints would get 401 Unauthorized, triggering login redirect.

Now they get 403 Forbidden with "Insufficient permissions" hint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:53:46 +01:00
sjg 65662a4f9b [fix](trx-frontend-http): return 403 for insufficient permissions, not 401
Fix auth middleware to return correct HTTP status codes:
- 401 Unauthorized: No session (not authenticated)
- 403 Forbidden: Has session but insufficient role

Previously, all auth errors returned 401, which caused the frontend
to redirect rx users to login when they tried control endpoints.

Now rx users scrolling jog wheel/frequency will get a "Insufficient
permissions" hint instead of being redirected to login.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:52:38 +01:00
sjg 00f82646c2 [refactor](trx-frontend-http): consolidate to single auth interface
Remove duplicate logout button from About tab. Use only the header
Login/Logout button for unified authentication control.

The About tab now shows the authentication badge (when logged in)
without the redundant logout button.

Single login view:
- Auth gate with Login form + Continue as Guest button (when no rx pass)
- Header Login/Logout button for quick access
- Auth badge in About tab showing current role

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:51:49 +01:00
sjg 4d959b649f [fix](trx-frontend-http): don't redirect to login on 403 (insufficient permissions)
Only redirect to login on 401 (unauthenticated). For 403 errors
(authenticated but insufficient role), let the caller handle the error.

This prevents rx-authenticated users from being redirected to login
when they attempt to scroll the jog wheel or frequency input, which
tries to call /set_freq (a control-only endpoint).

RX users will now see "Insufficient permissions" hint instead of
being sent to login screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:50:58 +01:00
sjg 4d0a9d6d90 [feat](trx-frontend-http): disable plugin enable/disable for rx role
Disable plugin toggle buttons for rx-authenticated users:
- FT8 decode toggle
- WSPR decode toggle
- CW auto checkbox

RX users cannot enable/disable decoders, preventing unintended
configuration changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:47:58 +01:00
sjg 62f96a1e53 [fix](trx-frontend-http): show auth gate after logout and disable jog/VFO for rx
Fix logout flow to properly show auth gate and clear form. Also
disable additional controls for rx role:
- Jog wheel (faded out)
- Jog up/down buttons
- VFO selector buttons

For rx-authenticated users, all frequency/mode adjustment controls
are now properly disabled to prevent accidental changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:47:40 +01:00
sjg 0b7158f465 [fix](trx-frontend-http): prevent browser hang on logout
Replace location.reload() with disconnect() + auth gate to prevent
browser hang when logging out. The full page reload was causing
issues with resource loading and event source reconnection timers.

Changes:
- Add disconnect() function to cleanly close EventSource connections
  and clear all timers (esHeartbeat, reconnectTimer)
- authLogout() now disconnects locally and shows auth gate instead
  of reloading the page
- Faster logout experience without full page reload

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:46:25 +01:00
sjg 8b9da52fe7 [feat](trx-frontend-http): add header auth button (Login/Logout)
Add a Login/Logout button in the header next to the theme toggle,
styled consistently with the theme button. Button behavior:

- When logged in: Shows "Logout" with confirmation
- When not logged in: Shows "Login" to open auth gate
- Visible when on main app (not in auth gate)
- Same theme-toggle-btn styling

Provides quick access to authentication controls without needing to
navigate to the About tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:44:52 +01:00
sjg 9d3b690aca [fix](trx-frontend-http): always show auth gate when no valid session
Do not auto-connect with guest role. Always show the auth gate when
there's no valid session, allowing the user to choose between:
- Login: Enter passphrase for control access
- Continue as Guest: Proceed with read-only access (if available)

This allows users to enter their control passphrase instead of being
forced into guest mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:43:14 +01:00
sjg 599fa42bb3 [feat](trx-frontend-http): add guest button to auth gate
When RX access is unrestricted (no rx_passphrase required), show
a "Continue as Guest" button on the auth gate to allow immediate
access without requiring login. The button is only shown when guest
mode is available.

Guest mode:
- No authentication required
- Grants rx role immediately
- Can monitor radio but cannot transmit

Login path still available for full control access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:41:36 +01:00
sjg 59ee8f5760 [feat](trx-frontend-http): grey out TX controls for rx-authenticated users
When a user is authenticated as 'rx' role (read-only), disable all
TX/PTT control buttons and frequency/mode inputs to prevent accidental
attempts to transmit. This provides clear visual feedback that these
controls are not available.

Controls disabled for rx role:
- PTT button
- Power button
- Lock button
- TX Audio button
- Frequency input
- Mode select
- TX Limit input/button
- Jog up/down buttons
- Jog step buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:39:31 +01:00
sjg 20d08f6c7c [feat](trx-frontend-http): allow unrestricted RX access when rx_passphrase not set
When HTTP auth is enabled but rx_passphrase is not configured, allow
unauthenticated users to access read-only endpoints (status, events,
decode, audio) without authentication. This enables monitoring-only
access while protecting TX control with a passphrase.

Changes:
- AuthMiddleware: Skip auth check for read routes when rx_passphrase is None
- session_status: Grant rx role to unauthenticated users when no rx passphrase required

Use case: Set only control_passphrase to protect TX/PTT while allowing
anyone on the network to monitor the radio.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:39:05 +01:00
sjg e3deac2731 [fix](trx-client): pass HTTP auth config from TOML to server
The HTTP server was hardcoding auth config with enabled=false,
ignoring the actual configuration from trx-client.toml. This prevented
authentication enforcement even when enabled with passphrases.

Solution: Store auth config values in FrontendRuntimeContext during
initialization in main.rs, then extract and use them in server.rs
build_server() instead of hardcoding.

Fixes auth bypass where unauthenticated users could access the web UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:36:01 +01:00
sjg 070409c280 [docs](trx-client): add example auth passphrases to printed config
Update example_toml() to include example values for rx_passphrase and
control_passphrase in the printed config output, so users can see what
these configuration fields should look like.

Now --print-config shows:
[frontends.http.auth]
enabled = false
rx_passphrase = "rx-passphrase-example"
control_passphrase = "control-passphrase-example"
tx_access_control_enabled = true
session_ttl_min = 480
cookie_secure = false
cookie_same_site = "Lax"

This helps users understand all available auth parameters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:33:20 +01:00
sjg 19a4440e7f [feat](trx-client): add home directory config search path
Add ~/.trx-client.toml to the config file search paths, making it easy
for users to place their config in the home directory.

Updated search order:
1. Path specified via --config CLI argument
2. ./trx-client.toml (current directory)
3. ~/.trx-client.toml (home directory) [NEW]
4. ~/.config/trx-rs/client.toml (XDG config)
5. /etc/trx-rs/client.toml (system-wide)

This provides a more standard Unix convention for user config files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:30:27 +01:00
sjg 4e43b5b533 [fix](trx-frontend-http): grant full access by default when auth is disabled
When HTTP authentication is disabled (the default), the /auth/session
endpoint now returns { authenticated: true, role: "control" } instead
of 404. This allows the frontend to proceed without showing a login
gate, providing the expected out-of-the-box experience.

With this change:
- Default behavior: no login required, full control access
- Auth enabled: login gate shown, roles enforced per config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:26:20 +01:00
sjg a1c0755ccc [style](trx-frontend-http): remove duplicate logo from login screen
Remove the redundant logo image from the auth gate. The header already
displays the logo, so this duplicate was unnecessary.

The login screen now shows only:
- "Access Required" heading
- "Enter passphrase to continue" subtitle
- Passphrase input
- Login button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:25:42 +01:00
sjg 6d058d4c7d [style](trx-frontend-http): align login button width with passphrase field
Add box-sizing: border-box to both the passphrase input and login button
to ensure padding is included in width calculations. This makes them
exactly the same width visually.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:25:32 +01:00
sjg 621865d69f [fix](trx-frontend-http): hide tabs until authentication is granted
Hide the Main/Plugins/About tab bar initially, only showing it after
the user successfully authenticates. This prevents navigation options
from being visible when access has not been granted.

Changes:
- Add display:none and id to tab-bar div in index.html
- Update showAuthGate() to hide tab-bar
- Update hideAuthGate() to show tab-bar

Now the UI flow is:
1. Header only (auth gate visible)
2. After login: Header + tabs + content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:24:52 +01:00
sjg 59dde88b2e [style](trx-client, trx-frontend-http): fix clippy warnings
- Derive Default for SameSite enum in auth.rs using #[default] attribute
- Derive Default for CookieSameSite enum in config.rs
- Replace and_then(|x| Some(y)) with map(|x| y) in extract_session_id()

All clippy warnings resolved. Tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:23:17 +01:00
sjg 0459bf16b1 [chore](trx-client): update example config to include HTTP auth section
Update ClientConfig::example_toml() to explicitly include all HTTP auth
config fields with their default values, so the --print-config output
displays the complete auth configuration section.

Also add #[allow(dead_code)] to session_ttl() method to suppress warning.

The example config now shows:
[frontends.http.auth]
enabled = false
tx_access_control_enabled = true
session_ttl_min = 480
cookie_secure = false
cookie_same_site = "Lax"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:20:25 +01:00
sjg 65e1073ea0 [feat](trx-frontend-http): complete HTTP authentication implementation (phases 4-5)
Phase 4: Frontend login gate and role-based UI
- Add auth-gate HTML overlay with passphrase form
- Implement checkAuthStatus, authLogin, authLogout functions
- Auth startup sequence checks /auth/session before connecting
- Apply role-based restrictions: hide PTT/TX controls for rx role
- Handle 401/403 errors in postPath, return to login screen
- Add logout button in About tab with auth role display
- Passphrase form shows generic error messages (no info leakage)

Phase 5: Documentation
- Update trx-client.toml.example with [frontends.http.auth] section
  - All config fields with inline documentation and examples
  - security notes about cookie settings
- Update README.md with HTTP Frontend Authentication section
  - Role model explanation (rx vs control)
  - Configuration example
  - Security considerations for local, LAN, and remote deployments
  - Architecture overview

UI Features:
- Login gate blocks main UI until authenticated
- Role badge shows authenticated status in About tab
- Error messages clear after 5 seconds
- Logout confirmation prevents accidental logouts
- Smooth transition from auth gate to main UI

All code compiles successfully. HTTP frontend build verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:18:49 +01:00
sjg a4b014d66a [feat](trx-frontend-http): implement HTTP authentication (phases 1-3)
Add optional passphrase-based authentication with two roles (rx/control),
session management, auth middleware, and protected routes.

Phase 1: Config model with HttpAuthConfig struct, CookieSameSite enum,
validation logic for enabled auth requiring at least one passphrase.

Phase 2: Auth module with:
- AuthRole enum (Rx, Control)
- SessionRecord and SessionStore for in-memory session management
- AuthConfig at runtime
- /auth/login, /auth/logout, /auth/session endpoints
- Constant-time passphrase comparison for timing attack mitigation

Phase 3: Integration with:
- AuthMiddleware for route protection with public/read/control classification
- Server-side AuthState setup with cleanup task for expired sessions
- Auth endpoints registered in api.rs configure()

Sessions use 128-bit random IDs (hex-encoded), HttpOnly cookies, configurable
SameSite attribute. Auth is disabled by default to preserve current behavior.

All unit and integration tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 08:15:55 +01:00
sjg 66989b306f [feat](trx-frontend-http): refine main UI controls and map visuals
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 02:43:04 +01:00
sjg c510efb331 [feat](trx-frontend-http): sync frequency input wheel with jog control
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 02:16:33 +01:00
sjg 1955d2491d [fix](trx-frontend-http): fix rig access path and unify control height
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 02:14:15 +01:00
sjg 4f35be539f [feat](trx-frontend-http): improve locator map plotting and themed filters
Refine map plotting and filter UX in HTTP frontend plugins.\n\n- support plotting multiple locator squares from FT8/WSPR messages\n- show locator lists in popup content as newline-separated entries\n- add WSPR map layer filter toggle and marker typing\n- style filter controls for strong dark/light mode contrast\n- keep themed behavior aligned with map and control updates\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 02:11:31 +01:00
sjg f637cf23d3 [feat](trx-frontend-http): add theme toggle and themed map/audio visuals
Add dark/light mode selector below logo and wire theme-aware visuals.\n\n- add compact theme toggle control under header logo\n- support persistent dark/light theme switching\n- use emoji labels for theme toggle actions\n- make jog and audio level visuals theme-aware\n- add dark-mode map tile layer and live layer switching on theme changes\n- keep responsive behavior for header graph and controls\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 02:08:40 +01:00
sjg 53ce663adc [feat](trx-frontend-http): enhance header telemetry and responsive UX
Implement UI refinements for the HTTP frontend main and plugin views.\n\n- add dimmed header signal graph with live rendering and scale\n- make graph responsive, colorized by signal strength, and keep last 10s only\n- add APRS and WSPR text filtering, matching FT8 behavior\n- refine responsive layout for controls/map/header behavior\n- tune jog wheel/button sizing and mode selector height alignment\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 02:03:20 +01:00
sjg 630a02789c [feat](trx-frontend-http): refine responsive main and map layout
Adjust responsive behavior and interaction details in the HTTP frontend.\n\n- switch signal measurement from sample-based to time-based averaging\n- move Transmit/Power below Mode+Tune on small viewports\n- add practical mobile breakpoints and width handling\n- resize and tune logo/top spacing/layout placement\n- make map height viewport-aware with adjustable minimum\n- improve FT8/WSPR control wrapping on small screens\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 01:50:10 +01:00
sjg 088a683c62 [feat](trx-frontend-http): streamline main control layout
Refine main control interactions and presentation in the HTTP frontend.\n\n- remove frequency and mode Set buttons\n- apply mode changes immediately on picker change\n- place Mode/Tune/Transmit-Power controls in one horizontal row\n- align control labels vertically across that row\n- move and enlarge MHz/kHz/Hz selector beside frequency input\n- keep Enter-to-set frequency behavior\n- switch signal measurement to elapsed-time averaging\n- enlarge header logo 2x\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 01:38:51 +01:00
sjg 55c70f0fb7 [feat](trx-rs): expose rigctl metadata in HTTP about tab
Add rigctl frontend visibility in HTTP status/about UI and refine frequency controls layout.\n\n- track rigctl listen endpoint and active rigctl client count in frontend runtime context\n- inject rigctl metadata into HTTP /events payload\n- show rigctl endpoint and rigctl client count in About tab\n- remove frequency Set button from UI\n- move MHz/kHz/Hz selector beside frequency input and enlarge it\n- center jog wheel row and keep Enter-to-set frequency behavior\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 01:33:54 +01:00
sjg 86ca1a60fb [fix](trx-rs): harden hamlib rigctl compatibility
Improve rigctl interoperability with hamlib/WSJT-X and stabilize FT-817 PTT handling.\n\n- support extended '+' command replies\n- accept decimal and MHz-style frequency inputs\n- retry set_freq rounded to 10 Hz on CAT alignment errors\n- add compatibility handling for get_level probes\n- broaden PTT command parsing and aliases\n- derive PTT capability dynamically from snapshot data\n- improve dump_state/dump_caps compatibility behavior\n- move temporary rigctl diagnostics to debug level\n- make FT-817 set_ptt more reliable with unlock/clear and double-send\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 01:30:04 +01:00
sjg c1a7eaa72d [fix](trx-frontend-rigctl): improve hamlib rigctl compatibility
Handle hamlib/netrigctl protocol quirks for command parsing and replies.\n\n- support extended '+' response format\n- accept decimal and MHz-style frequency inputs\n- retry set_freq rounded to 10 Hz on CAT alignment errors\n- accept get_level probes (e.g. KEYSPD)\n- accept broader PTT argument variants\n- add trailing 'done' to dump_state for compatibility\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 01:16:08 +01:00
sjg ca88eec131 [fix](trx-frontend-rigctl): add rigctld dumpcaps handshake
Return setting=value lines with a done terminator for dumpcaps commands so Hamlib netrigctl_open can parse capabilities.

Add a unit test that verifies dumpcaps output formatting.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 01:04:16 +01:00
sjg 6591fcfec7 [feat](trx-client): add local cpal audio bridge for WSJT-X
Add configurable bidirectional local audio bridge (RX playback + TX capture) using cpal/opus for virtual-device routing on Linux/macOS, and document bridge settings.

Also expand rigctl frontend command compatibility aliases for hamlib/WSJT-X clients.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 00:30:49 +01:00
sjg 81890b15a8 [fix](trx-core): expose rig min tuning step and align UI tuning
Add min_freq_step_hz to RigCapabilities, set backend values, and make HTTP frontend parse suffix-less frequency input using the selected unit while snapping set/jog frequencies to rig step granularity.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 00:17:16 +01:00
sjg a7719bd7a9 [fix](trx-server): make ALSA stream recovery reliable
Signal backend stream callback failures directly to capture/playback loops and strengthen cross-thread failure flag visibility so recurring POLLERR conditions consistently trigger stream recreation.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 00:12:12 +01:00
sjg c5b441f75b [fix](trx-frontend-http): adapt frequency input to selected unit
Make the frequency field render and parse values in the currently selected unit (MHz/kHz/Hz), including immediate refresh when switching jog step.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 00:07:53 +01:00
sjg a1d56dc1d0 [fix](trx-server): make PSK Reporter activation diagnosable
Require a receiver locator source when PSK Reporter is enabled,
show inactive reason in status text, and add periodic uplink runtime
counters (received/sent/skipped/errors).

This makes missing-spot issues visible instead of silently dropping
all decode events.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-13 00:05:54 +01:00
sjg d085f96838 [fix](trx-server): auto-recover audio streams and harden frontend reconnect
Implement ALSA/CPAL stream auto-recovery by recreating input/output
streams after backend callback failures with bounded retry delay.

Also improve HTTP frontend resilience by polling /status on reconnect
and after SSE errors to refresh snapshot state after broken pipes.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:59:39 +01:00
sjg 273283708e [fix](trx-server): preserve PSK Reporter status in snapshots
Pass pskreporter_status through RigTaskConfig and apply it to rig_task
state initialization so snapshot updates keep the About-tab value.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:50:41 +01:00
sjg e243f0e4cc [fix](trx-server): include legacy home config path and coords in print-config
Fix server config discovery to include ~/.trx-server.toml and update
example/print-config output to explicitly include general latitude and
longitude fields.

Also update config docs and add a test for the legacy search path.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:47:36 +01:00
sjg 28dab2d00f [feat](trx-server): expose PSK Reporter status in About
Add pskreporter_status to shared rig snapshots and display it in the
HTTP frontend About tab.

Also include audio stream error log throttling to avoid repetitive ALSA
error flooding in backend logs.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:37:15 +01:00
sjg e025be5fff [docs](repo): add complete configuration reference
Add CONFIGURATION.md documenting all server/client config options,
defaults, and validation constraints, and link it from README.md.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:33:19 +01:00
sjg db792cc9d8 [feat](trx-server): add PSK Reporter uplink for FT8/WSPR
Add a PSK Reporter uploader task that subscribes to decoded FT8/WSPR
messages and sends spots over UDP when [pskreporter].enabled is true.

Include new [pskreporter] server config options and example config docs,
and hardcode software identification as 'trx-server v<version> by SP2SJG'.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:31:04 +01:00
sjg 4b1252a3e3 [fix](trx-frontend-http): update WSPR slot countdown timer
Wire the WSPR period label to a live 120-second slot countdown so
it no longer stays at the placeholder value.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:24:11 +01:00
sjg eb3c69df39 [feat](trx-frontend-http): reorder plugin tabs and add FT8 slot timer
Reorder Plugins subtabs to align with the Overview plugin listing
(APRS, CW, FT8, WSPR), with Map moved to the end.

Also add a live FT8 period countdown indicator in the FT8 panel.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:22:37 +01:00
sjg 7b75049f4f [feat](trx-frontend-http): add dedicated WSPR plugin tab
Expose a WSPR subtab in the Plugins view with its own controls and
message list, wire a dedicated wspr.js asset endpoint, and route WSPR
decode events to the new panel.

This makes WSPR visible in the HTTP frontend instead of reusing the
FT8 panel for WSPR messages.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:18:36 +01:00
sjg 6d2d647511 [refactor](trx-wspr): remove external wsprd integration
Drop all external wsprd wrapper/build plumbing and keep trx-wspr on
an internal Rust-only decoder path.

This removes wsprd process dependencies and leaves a native decoder
scaffold with the same public API for incremental algorithm work.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 23:15:01 +01:00
sjg ae5edabc27 [refactor](trx-wspr): split wsprd wrapper from decoder
Extract external wsprd process invocation into a dedicated wrapper
module and keep WSPR decode orchestration in a separate decoder module.

This mirrors the ft8 crate layering and makes wsprd integration easier
to test and evolve.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 22:54:44 +01:00
sjg 091810c6cb [feat](trx-server): integrate wsprd-based WSPR decoding
Add a new trx-wspr crate that wraps wsprd slot decoding and parsed
results, wire it into the server audio pipeline, and emit WSPR decode
events to clients.

Also add frontend event routing for WSPR decode messages and temporary
rendering in the FT8 table until a dedicated WSPR panel is introduced.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 22:47:37 +01:00
sjg 63ba6882cd [feat](trx-core): add WSPR decode plumbing across stack
Introduce WSPR command/state/protocol/transport wiring and integrate lifecycle, history, and frontend API paths mirroring the FT8 architecture.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 22:38:05 +01:00
sjg 8e59e205a8 [docs](workspace): refresh post-refactor enhancement plan
Reanalyze current architecture status and rewrite ENHANCEMENT.md to reflect remaining high-impact issues after completed phases.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 22:29:23 +01:00
sjg 4b34a39745 [refactor](workspace): complete remaining architecture phases
Bundle all pending repository updates, including plugin context de-globalization, runtime hardening, config validation, boundary tests, and supporting docs/scripts.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 22:27:36 +01:00
sjg 144afbae8e [fix](trx-server): validate config semantics at startup
Add semantic validate() checks for server/client config models and fail fast on invalid ranges, field combinations, and auth token values before runtime startup.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 22:05:54 +01:00
sjg 3cc36d9c24 [fix](trx-client): harden json tcp parsing and io limits
Add typed remote endpoint parsing (including bracketed IPv6), bounded JSON line reads, and read/write/request timeouts across client/server JSON-TCP paths.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 21:57:24 +01:00
sjg cadb60f99d [refactor](trx-server): supervise runtime tasks and shutdown
Add coordinated shutdown signaling and task supervision for long-running server and client tasks to avoid detached runtimes on Ctrl+C.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 21:49:39 +01:00
sjg 6ce1b5be9d [fix](trx-ft8): use portable uint64 format macros
Replace llx specifiers with PRIx64 in external ft8 message logging to avoid platform-dependent printf warnings.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 21:23:10 +01:00
sjg 0aa2fcbbbb [refactor](trx-protocol): move transport dto out of core
Phase 5: relocate ClientCommand/Envelope/Response into trx-protocol and update call sites so trx-core remains domain-focused.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 21:19:42 +01:00
sjg 0d8314cab6 [refactor](trx-rs): adopt shared app infra modules
Phase 4: route config loading and plugin discovery through trx-app, remove duplicated local plugin loaders, and align binary dependencies.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 21:19:03 +01:00
sjg b7fb9adef7 [refactor](trx-rs): inject runtime contexts for io paths
Phase 3: replace frontend/backend hot-path globals with explicit runtime/registration context wiring while keeping plugin compatibility adapters.

Co-authored-by: Codex <codex@openai.com>,
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 21:18:42 +01:00
sjg 410fc89185 [refactor](trx-frontend): implement plugin compatibility adapter shim
Replace legacy global FrontendRegistry with bootstrap context adapter that
maintains backward compatibility while delegating to explicit context.

Changes:
- Create BOOTSTRAP_CONTEXT: OnceLock<Arc<Mutex<FrontendRegistrationContext>>>
- register_frontend(): delegates to bootstrap context
- is_frontend_registered(): reads from bootstrap context
- registered_frontends(): reads from bootstrap context
- spawn_frontend(): reads from bootstrap context

Result: Plugins continue calling global functions, but all operations
now route through the bootstrap context. Frontends receive context
parameter explicitly, enabling multiple concurrent instances.

Complete de-globalization achieved with full backward compatibility.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:56:15 +01:00
sjg d7f5dc83f8 [refactor](trx-backend): implement plugin compatibility adapter shim
Replace legacy global BackendRegistry with bootstrap context adapter that
maintains backward compatibility while delegating to explicit context.

Changes:
- Create BOOTSTRAP_CONTEXT: OnceLock<Arc<Mutex<RegistrationContext>>>
- register_backend(): delegates to bootstrap context
- is_backend_registered(): reads from bootstrap context
- registered_backends(): reads from bootstrap context
- build_rig(): reads from bootstrap context

Result: Plugins continue calling global functions, but all operations
now route through the bootstrap context instead of a separate global
registry. This completes the de-globalization while maintaining full
backward compatibility with existing plugins.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:56:11 +01:00
sjg ab9604dee9 [refactor](trx-client): create and thread FrontendRuntimeContext through bootstrap
Create FrontendRuntimeContext as Arc during async_init and pass it to all
spawn_frontend calls, enabling explicit context-based initialization.

Changes:
- Create frontend_runtime_ctx as Arc<FrontendRuntimeContext>
- Pass context to all spawn_frontend invocations in the frontend loop
- Update comment to reflect Phase 3C completion

This completes the threading of context through the client bootstrap,
moving away from global mutable state for audio channels, decode channels,
and authentication tokens.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:53:47 +01:00
sjg 673dc372e7 [refactor](trx-frontend-*): update frontend implementations for context parameter
Update all three built-in frontends to accept Arc<FrontendRuntimeContext>
parameter in their spawn_frontend implementations:

- trx-frontend-http: passes context to serve function
- trx-frontend-http-json: passes context to serve function
- trx-frontend-rigctl: accepts context (minimal impact, no globals used)

Frontends are now ready to use context for audio channels, decode
channels, and auth tokens instead of accessing globals directly.

This completes the trait signature change for all frontends.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:53:42 +01:00
sjg 1ebdbe91a9 [feat](trx-frontend): update FrontendSpawner trait to accept context
Update FrontendSpawner trait and related functions to accept and pass
Arc<FrontendRuntimeContext> parameter instead of relying on global
accessors for audio channels, decode channels, and auth tokens.

Changes:
- FrontendSpawner::spawn_frontend now accepts context parameter
- FrontendSpawnFn type signature includes context parameter
- FrontendRegistrationContext::spawn_frontend passes context to spawner
- Global spawn_frontend function accepts and passes context

This enables frontends to receive runtime data explicitly without
accessing globals, improving testability and supporting multiple
concurrent frontends with different contexts.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:53:38 +01:00
sjg adddfc1c3b [refactor](trx-client): create bootstrap frontend context
Demonstrate context-based frontend initialization in async_init.

Creates FrontendRegistrationContext and FrontendRuntimeContext at bootstrap
to establish the pattern for explicit frontend management instead of globals.

Full threading of context through spawn_frontend would require changing the
frontend trait signature and updating all frontend implementations - planned
for Phase 3C.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:49:41 +01:00
sjg 0a8a98ea54 [feat](trx-backend): add register_builtin_backends_on for context initialization
Add register_builtin_backends_on(context: &mut RegistrationContext) function
to allow explicit backend registration on a context instead of always using globals.

This enables proper initialization sequencing where backends are registered
on a specific context that can be passed through bootstrap.

The global register_builtin_backends() still works for plugin compatibility,
delegating to the new context-based approach.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:49:37 +01:00
sjg d8e9397cf6 [feat](trx-frontend): add context types for explicit initialization
Create explicit context types for frontend registration and runtime:

FrontendRegistrationContext:
- register_frontend(name, spawner) - register a frontend
- is_frontend_registered(name) - check if registered
- registered_frontends() -> Vec<String> - list all frontends
- spawn_frontend(name, ...) -> DynResult - spawn a frontend

FrontendRuntimeContext (NEW):
- audio_rx: broadcast channel for audio RX
- audio_tx: mpsc channel for audio TX
- audio_info: watch channel for audio stream metadata
- decode_rx: broadcast channel for decoded messages
- aprs_history: Arc<Mutex<VecDeque>> for APRS decode history
- cw_history: Arc<Mutex<VecDeque>> for CW decode history
- ft8_history: Arc<Mutex<VecDeque>> for FT8 decode history
- auth_tokens: HashSet for authentication

Replaces global mutable state with explicit context that can be
threaded through bootstrap. Maintains global API for compatibility.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:47:23 +01:00
sjg ef6d45b878 [feat](trx-backend): add RegistrationContext for explicit initialization
Create explicit RegistrationContext type for backend factory registration
instead of relying solely on global mutable state.

New RegistrationContext:
- register_backend(name, factory) - register a backend
- is_backend_registered(name) - check if registered
- registered_backends() -> Vec<String> - list all backends
- build_rig(name, access) -> DynResult - instantiate a rig

Maintains global API for plugin compatibility, delegates to context.
Paves way for threading context through bootstrap in Phase 3B.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:47:19 +01:00
sjg 8425d0bab6 [chore](root): update Cargo.lock with trx-app dependency
Lock file updated after adding trx-app crate to workspace.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:41:25 +01:00
sjg 1a2ae96b6a [refactor](trx-client): use trx-app for shared infrastructure
Replace client's local implementations with unified trx-app utilities.

Changes:
- Use trx_app::normalize_name() instead of local fn
- Depend on trx-app crate

This eliminates the client's copy of the normalize_name logic and ensures
both server and client use the same implementation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:39:22 +01:00
sjg 5d83fba8f5 [refactor](trx-server): use trx-app for shared infrastructure
Replace server's local implementations with unified trx-app utilities.

Changes:
- Use trx_app::normalize_name() instead of local fn
- Depend on trx-app crate

This eliminates the server's copy of the normalize_name logic and ensures
both server and client use the same implementation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:39:20 +01:00
sjg 30bf0f56ac [feat](trx-app): create shared app infrastructure crate
Create new trx-app crate to consolidate 334 lines of duplicate
infrastructure code from server and client binaries.

Modules:
- plugins: load_plugins() - unified plugin discovery and loading
- util: normalize_name() - centralized name normalization
- config: ConfigFile trait - generic config loading with default paths
- logging: init_logging() - unified logging initialization

Benefits:
- Eliminates duplicate plugins.rs (232 lines, 100% identical)
- Single normalize_name() function (previously 4+ instances)
- ConfigFile trait enables consistent config handling
- log_level config field now usable (feature previously broken)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:39:17 +01:00
sjg 079313313d [refactor](trx-client): use RigState constructors
Replace duplicated RigState initialization with new constructor methods.

Changes:
- Use RigState::new_uninitialized() in main.rs
- Use RigState::from_snapshot() in remote_client.rs
- Remove standalone state_from_snapshot() function

These constructors eliminate 155 lines of duplicated struct literals
and provide clear semantics for different initialization contexts.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:37:15 +01:00
sjg ab53d3ae3b [refactor](trx-server): use RigState constructors and fix config bug
Replace duplicated RigState initialization with new constructor methods.
This fixes a critical bug where rig_task was hardcoding 144.3 MHz/USB
instead of respecting config.initial_freq_hz and config.initial_mode.

Changes:
- Use RigState::new_with_metadata() in main.rs
- Use RigState::new_with_metadata() in rig_task.rs (FIX: now respects config)
- Remove 45-line build_initial_state() helper function

The rig_task bug fix ensures the configured initial frequency and mode
are applied when the rig task starts, instead of always using defaults.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:37:12 +01:00
sjg 81195f2159 [feat](trx-core): add RigState constructors and Default impls
Add new constructors and Default trait implementations to consolidate
RigState initialization patterns. This eliminates 195 lines of duplicated
struct literals across server and client.

New methods:
- RigState::new_uninitialized() - for client-side initialization
- RigState::new_with_metadata() - for server-side with config values
- RigState::from_snapshot() - convert RigSnapshot to full state
- Default for RigStatus - 2m calling frequency (144.3 MHz) in USB mode
- Default for RigControl - disabled with no active repeater settings

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:37:08 +01:00
sjg da31275a30 [chore](root): add trx-protocol to workspace
Update workspace Cargo.toml to include new trx-protocol crate
and update Cargo.lock with new dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:27:50 +01:00
sjg 046449974e [refactor](trx-frontend-http-json): consolidate protocol logic with trx-protocol
Use centralized trx-protocol crate for:
- parse_mode and envelope parsing
- command mapping (ClientCommand -> RigCommand)
- token validation via RegistryTokenValidator wrapper

RegistryTokenValidator maintains compatibility with global auth
registry pattern while leveraging shared protocol logic from
trx-protocol. Removes duplicate auth and codec functions.

No behavior changes, all tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:27:47 +01:00
sjg e5004e9a67 [refactor](trx-client): consolidate protocol logic with trx-protocol
Use centralized trx-protocol::rig_command_to_client for command
conversion, eliminating 62 lines of duplicate code in mode handling
and command mapping logic.

Updates remote_client to delegate to trx-protocol for bidirectional
command conversion. No behavior changes, all tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:27:45 +01:00
sjg 8718a5d8d1 [refactor](trx-server): consolidate protocol logic with trx-protocol
Use centralized trx-protocol crate for:
- parse_mode and mode string parsing
- parse_envelope with fallback behavior
- command mapping (ClientCommand -> RigCommand)
- token validation with SimpleTokenValidator

Removes 116 lines of duplicate code. Wraps validator in Arc for
safe sharing across async tasks. No behavior changes, all tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:27:42 +01:00
sjg 6f9658375f [feat](trx-protocol): create protocol unification crate
Add new crate to centralize protocol conversion logic:
- codec module: mode parsing/formatting, envelope parsing
- auth module: token validation, bearer prefix handling
- mapping module: ClientCommand <-> RigCommand conversion

Includes 76 comprehensive tests covering all command variants,
error cases, and round-trip conversions. Removes duplication
across listener, remote_client, and HTTP-JSON frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:27:38 +01:00
sjg 8b28f3615f [chore](trx-ft8): suppress unused code warnings in external C library
Add compiler flags to suppress C warnings from vendored ft8_lib:
- -Wno-unused-const-variable for db_power_sum array
- -Wno-unused-function for ft8_decode_multi_symbols

These are harmless warnings from external code we don't control.
Suppressing at build system level keeps external code unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:14:06 +01:00
sjg cf67574571 [docs](trx-rs): remove appkit frontend references
Remove AppKit frontend mentions from documentation:
- Update AGENTS.md project structure
- Remove AppKit from capabilities table in OVERVIEW.md
- Remove AppKit from frontends table in OVERVIEW.md
- Remove AppKit from Frontends section in README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:06:03 +01:00
sjg 55fde37924 [chore](trx-client): remove appkit frontend support
Remove macOS AppKit frontend (trx-frontend-appkit) and related code:
- Delete appkit crate directory
- Remove appkit dependency and feature from Cargo.toml
- Remove appkit imports, main thread handling, and config from main.rs
- Remove AppKit config struct from config.rs
- Remove appkit section from example config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 20:06:01 +01:00
sjg a53bd7a08f [docs](trx-rs): align AGENTS with contributing
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 19:57:05 +01:00
sjg a7ae47ff00 [chore](root): add git attrs and ignore
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-12 19:53:59 +01:00
sjg 2dfe4a30dc [feat](trx-backend): add ft450d cat backend
Co-authored-by: OpenAI <assistant@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 23:53:16 +01:00
sjg 176b3e306c [fix](trx-frontend-http): filter ft8 and refresh rf values
Co-authored-by: OpenAI <assistant@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 23:38:50 +01:00
sjg 3a739069c0 [fix](trx-frontend-http): avoid decode history deadlock
Co-authored-by: OpenAI <assistant@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 23:31:17 +01:00
sjg c24d5d0152 [fix](trx-frontend-http): persist decode history and fix ft8 locators
Co-authored-by: OpenAI <assistant@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 23:22:35 +01:00
sjg 211c3baf16 [fix](trx-frontend-http): send decode history on connect
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 23:17:44 +01:00
sjg 1bfc23fec7 [fix](trx-frontend-http): tokenize ft8 grids reliably
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 22:15:24 +01:00
sjg a8f7e9c8de Revert "[fix](trx-frontend-http): normalize ft8 locator spacing"
This reverts commit 5aa9502e37.
2026-02-09 22:15:00 +01:00
sjg 5aa9502e37 [fix](trx-frontend-http): normalize ft8 locator spacing
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 22:14:22 +01:00
sjg dc376c9afb [fix](trx-frontend-http): detect ft8 grids by scan
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 22:11:37 +01:00
sjg 0073f11c15 [fix](trx-frontend-http): robust ft8 grid highlighting
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 22:09:49 +01:00
sjg 2b6df0e738 [fix](trx-frontend-http): highlight ft8 grid tokens
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 22:06:39 +01:00
sjg 44a4c0df24 [fix](trx-frontend-http): parse ft8 locators reliably
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 22:04:12 +01:00
sjg bfc5e531cc [feat](trx-frontend-http): add ft8 locators and map filters
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:50:23 +01:00
sjg dbb58fa2b7 [feat](trx-frontend-http): add ft8 headers and rf freq
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:44:50 +01:00
sjg 0e856073be [docs](trx-frontend-http): add ft8 to overview
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:43:37 +01:00
sjg 9f2306aa71 [fix](trx-ft8): correct result buffer size
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:41:53 +01:00
sjg 995ddd7306 [fix](trx-ft8): align decode result layout
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:41:08 +01:00
sjg 4d32f017a8 [chore](trx-server): log ft8 toggle and offsets
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:38:18 +01:00
sjg 55693bb6e8 [fix](trx-server): align ft8 decode to time slots
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:35:51 +01:00
sjg a4a3f1464e [fix](trx-ft8): enable stpcpy on linux
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:31:39 +01:00
sjg 5f3ea48ef7 [chore](trx-rs): address clippy warnings
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:26:53 +01:00
sjg cf11c16096 [fix](trx-ft8): make decoder Send
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:25:19 +01:00
sjg c4ca178097 [fix](trx-ft8): make raw decode result copyable
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:23:52 +01:00
sjg 56041874da [fix](trx-ft8): update callsign hash constants
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:22:25 +01:00
sjg 1199ab85e9 [feat](trx-rs): add ft8 decoder
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 21:19:56 +01:00
sjg 7bd1a70607 [fix](trx-frontend-http): align cw controls
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 20:53:07 +01:00
sjg a22a648b5d [fix](trx-frontend-http-json): map cw control commands
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 20:51:55 +01:00
sjg 715e6eb9aa [fix](trx-core): handle cw control commands
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 20:51:14 +01:00
sjg 0daf9e27ae [feat](trx-rs): add cw auto/manual controls
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 20:50:31 +01:00
sjg dfc0f220e8 [feat](trx-frontend-http): show cw auto mode
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:58:10 +01:00
sjg f610bf7db3 [fix](trx-frontend-http): update decode status on mode change
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:54:32 +01:00
sjg b9c58844f0 [fix](trx-server): gate cw decode strictly by mode
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:50:08 +01:00
sjg 3dbf590472 [fix](trx-server): gate aprs decode strictly by mode
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:48:55 +01:00
sjg af9b44f4d4 [feat](trx-server): auto-decode cw regardless of mode
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:43:43 +01:00
sjg b3293f1de3 [feat](trx-rs): server-side decode and aprs byte rendering
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:38:25 +01:00
sjg 7b4bcb6f04 [feat](trx-frontend-http): auto-enable aprs and cw decode
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:36:12 +01:00
sjg 1500a26761 [feat](trx-server): retain aprs history for new clients
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:26:18 +01:00
sjg 8811aa59ee [fix](trx-core): avoid APRS JSON type collision
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-09 19:21:07 +01:00
sjg 88ccc7ab81 [feat](trx-frontend-http): add decoder toggle/clear UI and endpoints
Add POST endpoints for toggle_aprs_decode, toggle_cw_decode,
clear_aprs_decode, and clear_cw_decode. Add toggle buttons in APRS
and CW tabs. Render decoder enabled state from SSE updates. Clear
button now also resets server-side decoder state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 23:09:51 +01:00
sjg 4a3273653a [feat](trx-frontend-http-json): map decoder commands
Add SetAprsDecodeEnabled, SetCwDecodeEnabled, ResetAprsDecoder, and
ResetCwDecoder to the JSON TCP frontend command mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 23:09:43 +01:00
sjg 25eec2a7a8 [feat](trx-client): map decoder commands in remote client
Initialize decoder state fields and map new RigCommand/ClientCommand
variants through the remote client TCP bridge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 23:09:40 +01:00
sjg 548aa540ab [feat](trx-server): handle decoder toggle and reset commands
Process decoder commands as early returns in rig_task (no CAT needed).
Check aprs_decode_enabled/cw_decode_enabled flags in decoder tasks
alongside mode. Track reset_seq to trigger decoder.reset() on clear.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 23:09:37 +01:00
sjg b257f69389 [feat](trx-core): add decoder toggle and reset commands
Add aprs_decode_enabled, cw_decode_enabled, aprs_decode_reset_seq, and
cw_decode_reset_seq fields to RigState and RigSnapshot. Add corresponding
RigCommand and ClientCommand variants for toggling and resetting decoders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 23:09:32 +01:00
sjg 2feecbfe4f [fix](trx-frontend-http): fix decode SSE status detection
Replace HEAD probe with EventSource readyState check to properly
detect 404 vs connection drop. HEAD requests to SSE endpoints may
not behave reliably across all setups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:57:19 +01:00
sjg dba2f0a9fb [feat](trx-client): add diagnostic logging for decode pipeline
Log whether audio/decode is enabled at startup and warn when
/decode is requested but the decode channel was not set. Helps
diagnose broken decode pipelines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:54:12 +01:00
sjg b98475037c [fix](trx-server): use raw bytes for APRS position parsing
The APRS info field is raw AX.25 bytes, not valid UTF-8.
from_utf8_lossy inserts multi-byte replacement characters, causing
panics when the position parser sliced by byte index. Switch
parse_aprs_position, parse_aprs_compressed, parse_aprs_lat, and
parse_aprs_lon to operate on &[u8] instead of &str.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:48:48 +01:00
sjg 10c4b735cf [fix](trx-frontend-http): show decode SSE connection status
The decode EventSource silently retried on 404 leaving the status
stuck on "Waiting for server decode". Probe /decode first to
distinguish 404 (audio disabled) from real SSE errors and update
the APRS/CW status text accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:44:21 +01:00
sjg 04706151b1 [fix](trx-server): start audio capture when decoder tasks are subscribed
The capture thread only checked Opus broadcast subscribers to decide
whether to start cpal input. PCM tap subscribers (APRS/CW decoders)
were not considered, so decoding never started without a browser
audio client. Include pcm_tx receiver count in the has_receivers
check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:37:53 +01:00
sjg 3dd02d4208 [refactor](trx-frontend-http): remove browser-side APRS/CW decoding
Server-side decoding makes client-side decoders redundant. Remove
~1000 lines of browser-side Bell 202 AFSK, AX.25/APRS parsing, and
Goertzel CW decoding. The frontend now relies solely on the /decode
SSE endpoint for decoded data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:34:09 +01:00
sjg 8608ec76ad [feat](trx-frontend-http): add Clear button to APRS decode panel
Clears the packet list, map markers are unaffected.  Also wipes
the persisted packet history from localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:31:01 +01:00
sjg 998f454a3e [feat](trx-frontend-http): consume server-side APRS/CW decode via SSE
Add /decode SSE endpoint streaming decoded messages from the server.
Add decode channel OnceLock with set/subscribe pattern.

In the browser, connect to /decode EventSource and dispatch to
onServerAprs/onServerCw handlers.  APRS and CW plugins now receive
server-decoded data automatically while keeping browser-side decoding
as a fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:28:56 +01:00
sjg 50e1c44722 [feat](trx-client): relay server-side decoded messages to frontends
Handle APRS/CW decode message types (0x03/0x04) in audio_client,
deserialize and forward via broadcast channel.  Create decode channel
and pass to audio client and HTTP frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:28:48 +01:00
sjg f7f0e46021 [feat](trx-server): add server-side APRS and CW decoders
Port Bell 202 AFSK demodulator (correlation detector, PLL clock
recovery, NRZI+HDLC, AX.25/APRS parser) and Goertzel CW decoder
(auto tone scan, auto WPM via k-means, Morse lookup) from browser JS
to Rust.

Add PCM tap to audio capture thread, spawn APRS/CW decoder tasks
gated by rig mode (PKT for APRS, CW/CWR for CW).  Forward decoded
messages over the audio TCP wire using new message types 0x03/0x04.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:28:43 +01:00
sjg 042aab7199 [feat](trx-core): add decode types and wire protocol for APRS/CW
Add shared DecodedMessage enum (tagged serde), AprsPacket and CwEvent
types for server-side decoding.  Add AUDIO_MSG_APRS_DECODE (0x03) and
AUDIO_MSG_CW_DECODE (0x04) wire protocol constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:28:34 +01:00
sjg 90d57a290e [feat](trx-frontend-http): persist settings and APRS state across refresh
Add localStorage persistence (trx_ prefix) for UI settings:
- Jog step, RX/TX volume (app.js)
- CW WPM, tone, threshold, auto-detect flags (cw.js)
- APRS decoded packets and running state (aprs.js)

APRS decoder auto-restarts on page refresh if it was active,
and all decoded packets plus map markers are restored from storage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 22:05:44 +01:00
sjg 3489a74855 [feat](trx-frontend-http): display non-ASCII bytes as yellow hex in APRS info
Render non-ASCII characters in decoded APRS info text as yellow
[0xNN] hex tags. Printable ASCII is HTML-escaped to prevent XSS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 21:52:44 +01:00
sjg d9a496cfaf [feat](trx-frontend-http): use APRS symbol icons on map markers
Show the proper APRS symbol sprite icon on map markers instead of
generic green circles. Falls back to a green circle marker when no
symbol info is available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 21:49:04 +01:00
sjg 80aadf54ab [feat](trx-frontend-http): add interactive Map tab with Leaflet
Add a Map sub-tab under Plugins that displays an interactive
OpenStreetMap via Leaflet.js showing:
- Receiver location (blue marker) from server config lat/lon
- APRS station positions (green markers) updated in real-time

The map lazy-initializes on first tab switch, handles tile rendering
on tab visibility changes, and deduplicates station markers by
callsign. Also includes the fallback snapshot lat/lon fields in the
API layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 21:44:55 +01:00
sjg e4db312814 [feat](trx-client): propagate server_latitude/longitude from snapshot
Map server_latitude and server_longitude from RigSnapshot to RigState
in remote_client and set None defaults in standalone client init.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 21:44:47 +01:00
sjg 6e34323cb1 [feat](trx-server): add latitude/longitude config and propagation
Add latitude and longitude Option<f64> fields to [general] config
section and propagate through ResolvedConfig, RigTaskConfig, and
build_initial_state into the RigState/RigSnapshot pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 21:44:43 +01:00
sjg df409a2cd3 [feat](trx-core): add server_latitude and server_longitude to RigState and RigSnapshot
Propagate receiver location (WGS84 decimal degrees) through the state
pipeline so frontends can display the server position on a map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 21:44:38 +01:00
sjg 7828402ab6 [feat](trx-frontend-http): add APRS symbols, map links, and correlation demodulator
- Add APRS symbol icons using hessu/aprs-symbols sprite sheets
- Parse uncompressed and compressed position formats for lat/lon
- Render clickable OpenStreetMap links for position packets
- Replace delay-and-multiply discriminator with mark/space correlation
  detector for more robust AFSK decoding
- Reduce PLL gain from 0.7 to 0.4 for stable clock recovery
- Move plugin JS files to plugins/ subdirectory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 21:19:14 +01:00
sjg ef1ffb11ec [feat](trx-frontend-http): add clear button to CW decoder output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 15:31:28 +01:00
sjg af50c2b6ee [feat](trx-frontend-http): add auto tone and WPM detection to CW decoder
Add Auto checkboxes (checked by default) next to WPM and Tone inputs.
Auto Tone uses a multi-bin Goertzel scan across 300-1200 Hz with
stability tracking. Auto WPM collects on-durations in a rolling buffer
and uses k-means-style clustering to separate dits from dahs.
Unchecking Auto allows manual control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 15:30:42 +01:00
sjg 62471c0336 [feat](trx-frontend-http): extract APRS to separate file and add CW decoder plugin
Extract the APRS decoder from app.js into its own aprs.js file and add
a new CW (Morse code) decoder plugin in cw.js. The CW decoder uses a
Goertzel tone detector with configurable WPM, tone frequency, and
signal threshold. The Plugins tab now has three sub-tabs: Overview,
APRS, and CW.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 15:24:52 +01:00
sjg 5205c067fc [fix](trx-frontend-http): improve APRS demodulator with pre-filter and moving-average LPF
Replace bandpass filter envelope detector with delay-and-multiply
frequency discriminator. Add biquad bandpass pre-filter centered at
1700 Hz to remove out-of-band noise. Replace IIR low-pass with
moving-average LPF over half a bit period, which places a null at
2x mark frequency for clean discriminator artifact removal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 15:05:17 +01:00
sjg 1a88a5e5de [feat](trx-server): serve GetSnapshot from watch channel in listener
Add fast path in the TCP listener to serve GetSnapshot requests
directly from the state watch channel, so clients get a response
even while the rig task is initializing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 15:00:28 +01:00
sjg 964a131408 [fix](trx-frontend-http): rewrite APRS demodulator with bandpass filter approach
Replace the coherent correlation detector with a non-coherent
bandpass filter + envelope detection approach for significantly
better frequency discrimination between mark (1200Hz) and space
(2200Hz) tones. Uses two cascaded 2nd-order IIR biquad filters
per tone with IIR-smoothed envelope detection.

Replace hard clock reset with PLL-style gradual correction for
smoother bit timing recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 15:00:24 +01:00
sjg 4c5e04bfc3 [fix](trx-frontend-http): add energy gate and show CRC-failing APRS frames
Add silence detection that resets the demodulator state when RMS
drops below threshold, so burst-mode APRS packets after squelch
activation start with a clean correlator and clock recovery.

Display CRC-failing frames at reduced opacity with a [CRC] tag to
aid debugging. Enhanced CRC-fail console logging now includes
attempted address decode, bit count, and hex dump.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 14:55:52 +01:00
sjg b5f40fc58a [feat](trx-frontend-http): color VFOs and rework header title
Assign green to VFO A, yellow to VFO B, and deterministic hues
to additional VFOs. The active VFO color is also applied to the
main frequency display. Rework the header title to show
"trx-rs <version> @ <callsign>'s <rig>" instead of "<rig> status".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 14:44:45 +01:00
sjg b042d679fa [fix](trx-frontend-http): fix APRS HDLC decoder and add debug logging
Fix three bugs in the Bell 202 AFSK demodulator preventing frame
decoding:
- Separate NRZI state from clock recovery (shared lastBit variable
  caused NRZI to always output 1)
- Buffer 1-bits in ones counter instead of pushing to frameBits
  immediately, preventing flag/stuff bits from contaminating frame
  data and corrupting byte alignment
- Detect flags via ones-count on decoded bits instead of shift
  register on raw bits

Also add framed packet log container styling, remove redundant
description text, and add debug counters logging pipeline health
to the browser console.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 14:29:35 +01:00
sjg d44390db00 [feat](trx-frontend-http): add APRS decoder plugin with sub-tab navigation
Replace server-side /frontends endpoint with client-side plugin system.
The Plugins tab now has Overview and APRS sub-tabs. The APRS plugin
decodes packets from RX audio using Bell 202 AFSK demodulation (1200
baud), AX.25 frame decoding with NRZI/HDLC, and CRC-16-CCITT
validation. Decoded packets are displayed in a scrolling log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 14:11:58 +01:00
sjg 7eaa39ea4a [feat](trx-frontend-http): add Plugins tab showing registered frontends
Add GET /frontends API endpoint returning registered frontend names as
JSON. Add Plugins tab to the web UI that fetches and displays the list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:59:51 +01:00
sjg d7d5674683 [style](trx-frontend-http): increase header logo size to 5em
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:54:02 +01:00
sjg 494d8979a8 [feat](trx-frontend-http): fix tab panel nesting, enrich About tab, move logo
Close #tab-main properly so the footer and About tab are not nested
inside it — footer is now visible on all tabs.

Add server address, rig connection method, supported modes, and VFO
count to the About tab.

Move logo from translucent centered overlay to solid image in the
top-right of the header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:52:14 +01:00
sjg 9a4a238dcf [fix](trx-frontend-http): add spacing between content and footer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:46:50 +01:00
sjg e60f3db4b2 [feat](trx-frontend-http): add tab navigation with Main and About tabs
Add a tab bar to the HTTP frontend card. All existing controls stay in
the Main tab. A new About tab shows server version, callsign, rig info,
client version, and connected client count, updated live via SSE.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:40:05 +01:00
sjg 91e599cae0 [fix](trx-frontend-http): handle plain GET on /audio endpoint
Return 204 for non-WebSocket GET requests to /audio instead of
letting actix-ws reject with 400. This allows the browser's audio
availability probe to work correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:15:19 +01:00
sjg 245e025134 [fix](trx-server): apply --listen flag to audio TCP listener
The audio listener was ignoring the CLI --listen override and always
binding to the config default (127.0.0.1), making it unreachable
from remote clients.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:12:04 +01:00
sjg ee7f0360fb [fix](trx-server): pause audio playback stream when idle
Start the output stream paused and only play when TX packets
arrive. Pause again when the packet queue drains to prevent
continuous ALSA buffer underruns (EPIPE errno -32) on Linux.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 13:07:28 +01:00
sjg 22482a34c3 (fix)[trx-rs]: remove Rust crate dependencies from README.md
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 12:16:10 +01:00
sjg 0a1447bb4d [feat](trx-backend): fluctuate dummy rig signal strength randomly
Use subsecond nanosecond jitter to return a varying signal strength
(2-8) from the dummy backend instead of a static value of 5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 12:14:43 +01:00
sjg c67e8feb9c [feat](trx-frontend-http): replace signal graph with measurement mode
Replace the rolling canvas signal graph with a practical signal
measurement feature. The operator can start/stop measurement to
collect signal samples, then view averaged and peak S-unit results.

Also fix signal display to correctly convert dBm wire format to
S-units using standard S-meter scale (S1=-121dBm, S9=-73dBm,
6dB per S-unit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 12:14:30 +01:00
sjg 822a19588f [style](trx-frontend-http): update copyright footer with callsign link
Shorten to "Built by SP2SJG" and link callsign to qrzcq.com.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 10:45:09 +01:00
sjg a0ca4ceeda [feat](trx-frontend-http): callsign link, centered freq, title, jog lock
- Make server callsign a clickable link to qrzcq.com
- Center frequency input text and use DSEG14 Classic font
- Include callsign in page title (e.g. N0CALL - trx-frontend-http v0.1.0)
- Block jog wheel when rig lock is active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 10:43:34 +01:00
sjg a8e09f2f0b [feat](trx-frontend-http): use DSEG14 Classic font for frequency display
Load DSEG14 Classic 14-segment LCD font from CDN and apply it to
the frequency input at 2x size for a realistic radio display look.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 10:37:59 +01:00
sjg be497b78cb [feat](trx-frontend-http): volume controls, server callsign, UI improvements
- Add RX/TX volume sliders with GainNode audio chain integration,
  scroll wheel support, and percentage display
- Display server callsign and version from SSE event data
- Merge TX Limit hint into its label line
- Add copyright footer with link to haxx.space
- Uniform section spacing via flex gap on #content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 10:32:23 +01:00
sjg a3b1702710 [feat](trx-client): preserve server callsign/version and default to N0CALL
Carry server_callsign and server_version through from received
snapshots into the local RigState. Default client callsign is
N0CALL when not configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 10:32:12 +01:00
sjg 454ff94e47 [feat](trx-server): populate server callsign/version and default to N0CALL
Set server_callsign and server_version on the rig state so they
are included in snapshots sent to clients. Default callsign is
N0CALL when not configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 10:32:02 +01:00
sjg e5428209d8 [feat](trx-core): add server_callsign and server_version to RigState/RigSnapshot
Add optional server_callsign and server_version fields to both
RigState and RigSnapshot so that server identity information can
flow through the protocol to clients and frontends.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 10:31:55 +01:00
sjg 37c36d196e [fix](trx-frontend-http): uniform buttons, jog wheel, VFO picker, signal graph, and client count
Reorganize layout: frequency row with jog wheel on top, mode and
transmit/power side by side below. Replace VFO text box with segmented
picker. Add rolling signal history canvas. Track connected SSE clients
and display count in status hint. Unify button heights and add Enter
key support for TX limit input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-08 09:53:33 +01:00
sjg b142d68ca2 [fix](trx-server): fix busy-loop in rig polling caused by interval recreation
time::interval() fires its first tick immediately. Recreating it on
every loop iteration made the select! always resolve instantly,
turning the main polling loop into a busy-loop (~13% CPU idle).

Replace with a Box::pin(sleep()) that is only reset after it
completes or when the poll duration changes (rx/tx transition).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 15:14:31 +01:00
sjg 75de1e50b9 [fix](trx-server): pause audio capture when no clients are connected
Instead of running the cpal input stream continuously, start it
paused and only activate when broadcast subscribers are present.
When the last client disconnects, pause the stream and sleep at
100ms intervals polling for new receivers.

This eliminates idle CPU usage from continuous CoreAudio callbacks,
channel allocations, and sample processing when nobody is listening.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 15:14:16 +01:00
sjg 90b981ef49 [fix](trx-client): enable audio by default
Enable audio streaming by default in AudioClientConfig so the
client connects to the server audio port without requiring
explicit configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 15:05:34 +01:00
sjg 3b606688bc [fix](trx-server): enable audio by default and fix playback thread panic
Enable audio streaming by default in AudioConfig.

Fix panic in audio playback thread caused by calling
tokio::runtime::Handle::current() from a plain std::thread.
Use rx.blocking_recv() instead, which is the correct API for
consuming a tokio mpsc receiver from a synchronous context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 15:05:25 +01:00
sjg 96edf7fc09 [fix](trx-frontend-http): misc JS cleanup and UX polish
Move window._opusDecoder, window._txEncoder, window._nextPlayTime
into closure-scoped variables to avoid polluting the global namespace.

Add showHint() helper to debounce status hint text, preventing
multiple button handlers from fighting over powerHint.textContent.

Throttle audio level indicator updates to max 10/sec instead of
updating on every Opus packet (~50/sec).

Hide audio controls row if the server has no audio configured
(checks /audio endpoint on load).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 15:00:10 +01:00
sjg 57d5b0633c [fix](trx-frontend-http): add PTT safety timeout and audio compatibility fallback
Add a 120-second TX safety timeout that auto-releases PTT if no mic
data flows (browser crash, disconnect). Timer resets on each audio
callback. Shows countdown in status when < 10s remaining.

Add beforeunload handler that releases PTT via navigator.sendBeacon
when the browser tab is closed during TX.

Detect WebCodecs support on page load and show "Audio requires
Chrome/Edge" on non-Chromium browsers instead of silently failing.

Remove dead playBuffer/playNode variables that were declared but
never used. Fix TX AudioData to always use mono (channel 0) with
numberOfChannels: 1 matching the f32-planar format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:52:51 +01:00
sjg 83342628fa [fix](trx-frontend-http): add CSS variables, focus states, and mobile breakpoints
Replace all hardcoded colors with CSS custom properties on :root for
easier theming. Add focus-visible outlines for keyboard accessibility.
Add mobile breakpoints for screens <= 480px.

Fix PTT button styling to use theme-aware colors (var(--accent-red))
instead of hardcoded light-theme colors (#ffefef, #f3f3f3).

Remove unused .value, .controls, and .section-title CSS classes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:51:18 +01:00
sjg 20fbd3c2cd [fix](trx-frontend-http): compress images and fix SSE heartbeat timing
Resize favicon from 1024x1024 to 64x64 and logo from 1024x1024 to
256x256, reducing total image size from ~3 MB to ~70 KB.

Fix SSE heartbeat race condition where the 10s ping interval competed
with the 8s stale threshold, causing spurious reconnects. Now pings
every 5s server-side, with a 15s stale threshold and 5s check interval
client-side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:50:25 +01:00
sjg 2bda78d724 [refactor](trx-frontend-http): extract HTML/CSS/JS into separate asset files
Split the monolithic status.rs string template into three files under
assets/web/ (index.html, style.css, app.js) loaded via include_str!.
Add /style.css and /app.js endpoints with correct content types.

This makes the frontend editable with proper syntax highlighting and
linting support in editors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:49:06 +01:00
sjg e9830adaea [docs](trx-rs): document audio streaming and new dependencies
Add Audio streaming section and Dependencies table covering system
libraries (libopus, cmake, pkgconf) and new Rust crates (cpal, opus,
bytes, actix-ws).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:23:14 +01:00
sjg 079310fcc4 [feat](trx-frontend-http): add audio WebSocket endpoint with auto-PTT
Add /audio WebSocket endpoint that streams RX Opus frames to the
browser and accepts TX frames back. Browser UI includes RX/TX Audio
toggle buttons with WebCodecs Opus decode/encode and a level indicator.
TX audio automatically engages PTT on start and releases on stop or
WebSocket disconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:23:07 +01:00
sjg a8aeeaca4e [feat](trx-client): add audio TCP proxy with auto-reconnect
Connect to the server audio TCP port, relay RX Opus frames into a
broadcast channel and TX frames from an mpsc channel. Pass audio
channels to the HTTP frontend via set_audio_channels. Reconnects
with exponential backoff on disconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:22:56 +01:00
sjg eba13ac2c2 [feat](trx-server): add audio capture and TCP streaming
Add AudioConfig to server configuration with support for RX capture
and TX playback via cpal and Opus encoding. Run a dedicated TCP
listener (default port 4533) that sends StreamInfo on connect, streams
RX Opus frames to clients, and receives TX frames back.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:21:59 +01:00
sjg 771cbd1987 [feat](trx-core): add audio protocol types and framing helpers
Add audio streaming wire protocol: message type constants (StreamInfo,
RxFrame, TxFrame), AudioStreamInfo struct, and async read/write helpers
for length-prefixed frames over AsyncRead/AsyncWrite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 14:21:42 +01:00
sjg a664a5f1a1 [fix](trx-client): give AppKit the process main thread
Replace #[tokio::main] with a manual fn main() that builds the tokio
runtime explicitly. All async initialization moves into async_init().

When the appkit frontend is requested, the runtime context is entered
on the main thread and run_appkit_main_thread() is called directly,
giving AppKit thread 0 as required by MainThreadMarker. Ctrl+C is
handled via a spawned task that calls process::exit.

When appkit is not requested, behaviour is unchanged: block on Ctrl+C.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 13:36:54 +01:00
sjg e7ddaa7300 [fix](trx-frontend-appkit): run AppKit event loop on calling thread
Extract the AppKit event loop from FrontendSpawner::spawn_frontend into
a new public run_appkit_main_thread() function that blocks on the
calling thread. This allows the process main thread (thread 0) to drive
the UI, which is required for MainThreadMarker::new() to succeed.

The FrontendSpawner impl now only spawns the async state watcher task.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 13:36:32 +01:00
sjg a7582f17f2 [feat](trx-client): default to port 4532 when not specified in URL
Allow connecting with just an IP address (e.g. --url 127.0.0.1)
instead of requiring host:port format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 13:03:27 +01:00
sjg c4007f16e3 [feat](trx-server): add JSON TCP listener for client connections
Add a JSON-over-TCP listener so trx-client can connect to trx-server.
Speaks the ClientEnvelope/ClientResponse protocol from trx-core::client.

- New listener.rs module with per-client connection handling
- ListenConfig/AuthConfig in config.rs (default: 127.0.0.1:4532)
- CLI args --listen and --port for override
- Optional token-based authentication
- Updated example config with [listen] section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 13:03:19 +01:00
sjg 3d137fabb1 [feat](trx-backend): add dummy rig backend
Register a dummy rig backend that holds state in memory and responds
to all CAT commands immediately. Useful for development and testing
without hardware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 13:03:05 +01:00
sjg e7512b3cf0 [feat](trx-frontend-appkit): add native macOS AppKit frontend
Add a new trx-frontend-appkit crate using objc2 + AppKit as a
replacement for the removed Qt/QML frontend. The frontend provides
the same feature set: frequency/mode/band display, PTT/power/VFO/lock
controls, signal/TX metering, and frequency/mode/TX-limit input.

Architecture splits platform-agnostic model (model.rs) from AppKit
UI (ui.rs) to facilitate future UIKit porting. State flows from the
async tokio watcher via std::sync::mpsc to the AppKit main thread;
button actions flow back through a channel to stay on the UI thread.

Feature-gated behind `appkit-frontend` cargo feature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 09:25:13 +01:00
sjg 2c128127e6 [refactor](trx-client): remove Qt/QML frontend support
Remove the Linux-only Qt/QML frontend (trx-frontend-qt) crate and all
references to it from the workspace, trx-client binary, configuration,
and documentation. This prepares for replacement with a native macOS
AppKit frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-07 09:10:46 +01:00
sjg 004eea0000 [docs](trx-frontend-qt): update trx-bin reference to trx-client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-06 23:02:21 +01:00
sjg 28cd7fe577 [docs](trx-plugin-example): update paths and references
Update dependency paths to new trx-backend and trx-frontend
locations. Replace trx-bin references with trx-server/trx-client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-06 23:02:13 +01:00
sjg 11f1a095f6 [docs](trx-rs): update docs and split example configs
Replace trx-rs.toml.example with separate trx-server.toml.example
and trx-client.toml.example. Update OVERVIEW.md and README.md
references from trx-bin to trx-server/trx-client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-06 23:02:03 +01:00
sjg e609022356 [docs](trx-rs): add CONTRIBUTING.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2026-02-06 23:01:32 +01:00
sjg 5f91287369 refactor: nest trx-frontend under trx-client, trx-backend under trx-server
Move the frontend and backend crate trees to live physically under their
respective binary crate directories, grouping related code together
without merging crate boundaries. Also flatten sub-crate nesting by
moving them out of src/ subdirectories into direct children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:47:58 +01:00
sjg 4e9f1d2be6 refactor: split into independent trx-server and trx-client binaries
Delete trx-bin (all-in-one) and trx-bin-common (shared lib). Each binary
now has its own config, plugins, and helper modules inlined.

- trx-server: backend-only daemon with ServerConfig (general, rig, behavior)
  no frontend dependencies
- trx-client: remote client with ClientConfig (general, remote, frontends)
  includes all frontend support (http, rigctl, http-json, qt)
- Dedicated config files: trx-server.toml / trx-client.toml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 22:44:04 +01:00
sjg ee25271275 docs: update overview and sample config 2026-01-18 09:24:16 +01:00
sjg bceb049e0e bin: add config loader and remote client mode 2026-01-18 09:24:06 +01:00
sjg cbd500edae workspace: wire new frontends into workspace 2026-01-18 09:23:56 +01:00
sjg 74d06e7a7c frontend: add new qt, rigctl, and json frontends 2026-01-18 09:23:25 +01:00
sjg a941c77039 rig: integrate controller and rig task updates 2026-01-18 09:20:10 +01:00
sjg 1be08b245c registry: add backend/frontend registries and plugin loader 2026-01-18 09:19:37 +01:00
sjg 6ef16f2cf4 core: make rig snapshot serializable 2026-01-18 09:18:54 +01:00
sjg 025eb237b2 initial commit
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
2025-11-30 23:54:05 +01:00
434 changed files with 24445 additions and 52353 deletions
-4
View File
@@ -1,7 +1,3 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Enable CPU optimizations for better performance # Enable CPU optimizations for better performance
# Set target-cpu to native to use all available CPU features on the build machine # Set target-cpu to native to use all available CPU features on the build machine
+7
View File
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[submodule "docs"]
path = docs
url = http://github.com/sgrams/trx-rs.wiki.git
+26 -56
View File
@@ -39,30 +39,24 @@ This is a Cargo workspace. All crates live under `src/`:
``` ```
src/ src/
trx-core/ # Core types, traits, state machine, controller (~3,500 LOC) trx-core/ # Core types, traits, state machine, controller
trx-protocol/ # Client↔server protocol DTOs, auth, codec, mapping (~1,100 LOC) trx-protocol/ # Client↔server protocol conversion, auth, codec
trx-app/ # Shared application helpers (config paths, logging init) trx-app/ # Shared application helpers (config, plugins, logging)
trx-reporting/ # PSKReporter UDP uplink + APRS-IS TCP uplink (~1,150 LOC) trx-server/ # Server binary (rig_task, audio, APRS-IS, PSKReporter)
trx-server/ # Server binary: rig_task, audio pipeline, listener (~3,700 LOC) trx-backend/ # Backend abstraction trait + factory
trx-backend/ # Backend abstraction trait + factory + dummy trx-backend-ft817/ # Yaesu FT-817 CAT implementation
trx-backend-ft817/ # Yaesu FT-817 binary CAT (BCD encoding) trx-backend-ft450d/ # Yaesu FT-450D CAT implementation
trx-backend-ft450d/ # Yaesu FT-450D ASCII CAT trx-client/ # Client binary (connects to server, runs frontends)
trx-backend-soapysdr/ # SoapySDR RX with full DSP pipeline (~5,000+ LOC) trx-frontend/ # Frontend trait (FrontendSpawner)
trx-client/ # Client binary: remote connection, frontend spawning (~1,500 LOC) trx-frontend-http/ # Web UI with REST API, SSE, and auth
trx-frontend/ # Frontend trait (FrontendSpawner), runtime context
trx-frontend-http/ # Web UI: REST API, SSE, WebSocket audio, session auth
trx-frontend-http-json/ # JSON-over-TCP control frontend trx-frontend-http-json/ # JSON-over-TCP control frontend
trx-frontend-rigctl/ # Hamlib-compatible rigctl TCP interface trx-frontend-rigctl/ # Hamlib-compatible rigctl TCP interface
trx-configurator/ # Interactive setup wizard
decoders/ decoders/
trx-aprs/ # APRS packet decoder (AX.25 + APRS-IS) trx-aprs/ # APRS packet decoder
trx-cw/ # CW (Morse) decoder (Goertzel tone detection) trx-cw/ # CW (Morse) decoder
trx-ftx/ # Pure Rust FTx decoder (FT8/FT4/FT2, LDPC/OSD) (~3,000+ LOC) trx-ft8/ # FT8 decoder (wraps external ft8_lib C library)
trx-wspr/ # WSPR weak-signal decoder trx-wspr/ # WSPR decoder
trx-ais/ # AIS maritime transponder decoder trx-decode-log/ # Shared decoder logging (JSON Lines, date-rotated files)
trx-rds/ # RDS decoder for WFM (~2,000 LOC)
trx-vdes/ # VDES maritime data exchange decoder (~1,300 LOC)
trx-decode-log/ # JSON Lines file logging with date rotation
``` ```
## Architecture ## Architecture
@@ -71,14 +65,14 @@ The project is split into a **server** (connects to the radio hardware) and a **
### Data flow ### Data flow
```mermaid ```
graph TD Radio hardware
Radio["Radio Hardware"] <-->|serial / TCP| Server["trx-server (rig_task.rs)"] ↕ serial/TCP
Server <-->|"JSON-TCP :4530"| Client["trx-client (remote_client.rs)"] trx-server (rig_task.rs)
Server -->|"Opus-TCP :4531"| Client ↕ trx-protocol JSON-TCP (port 4530)
Client <-->|internal channels| F1["HTTP Frontend :8080"] trx-client (remote_client.rs)
Client <-->|internal channels| F2["rigctl Frontend :4532"] internal channels
Client <-->|internal channels| F3["JSON-TCP Frontend"] Frontends: HTTP (8080), rigctl (4532), http-json (ephemeral)
``` ```
### trx-core controller ### trx-core controller
@@ -92,11 +86,11 @@ The rig controller (`src/trx-core/src/rig/controller/`) is the central state man
### Decoders ### Decoders
Signal decoders run as background tasks in `trx-server`, consuming decoded audio. `trx-ftx` provides the FT8/FT4/FT2 decoder in pure Rust. Decoded frames can be forwarded to PSKReporter and APRS-IS (IGate) uplinks, or logged via `trx-decode-log`. Signal decoders run as background tasks in `trx-server`, consuming decoded audio. `trx-ft8` wraps a C library (`external/ft8_lib`). Decoded frames can be forwarded to PSKReporter and APRS-IS (IGate) uplinks, or logged via `trx-decode-log`.
## Diagrams ### Plugin system
Always use [Mermaid](https://mermaid.js.org/) for diagrams in Markdown files. Never use ASCII art, box-drawing characters, or plain-text diagrams. GitHub renders Mermaid natively in ```mermaid fenced code blocks. Both `trx-server` and `trx-client` can load shared-library plugins exporting a `trx_register` symbol. Search paths: `./plugins`, `~/.config/trx-rs/plugins`, `TRX_PLUGIN_DIRS` env var.
## Commit Format ## Commit Format
@@ -105,27 +99,3 @@ Always use [Mermaid](https://mermaid.js.org/) for diagrams in Markdown files. Ne
``` ```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. Use `(trx-rs)` for repo-wide changes. Sign commits with `git commit -s`. Write isolated commits per crate. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`. Use `(trx-rs)` for repo-wide changes. Sign commits with `git commit -s`. Write isolated commits per crate.
## Codebase Review Observations
Full architecture documentation: `docs/Architecture.md`
Improvement plan: `docs/Improvement-Areas.md`
*Last reviewed: 2026-03-29*
### Strengths
- **Explicit state machine**: `RigMachineState` FSM (7 states) prevents invalid states with a deterministic transition table and exhaustive matching. Well-tested with lifecycle, error recovery, and invalid transition tests. `ReadyStateData`/`TransmittingStateData` use `pub(crate)` fields with controlled accessors.
- **Trait-based polymorphism**: Clean abstraction boundaries (`RigCat`, `RigSdr`, `AudioSource`, `RigListener`, `RigCommandHandler`, `CommandExecutor`, `TokenValidator`, `FrontendSpawner`) enable loose coupling and testability. `RigCat`/`RigSdr` split cleanly separates CAT ops from SDR-specific methods.
- **Multi-rig architecture**: Per-rig task isolation with `HashMap<rig_id, RigHandle>` routing, per-rig state/spectrum/audio/decoder-history channels, dual-connection model (main + spectrum) in the client, and backward-compatible single-rig mode.
- **Async concurrency model**: Proper use of tokio channels -- `watch` for state snapshots, `broadcast` for PCM/decode fan-out, `mpsc` for commands. No mutex contention on hot paths. Spectrum deduplication collapses concurrent GetSpectrum requests.
- **Comprehensive SDR support**: Full DSP pipeline with multi-mode demodulation (SSB, AM, SAM, FM, WFM, AIS, VDES), virtual channel management, squelch, noise blanker, spectrum FFT, RDS decoding. AVX2-optimized FM discriminator with scalar fallbacks.
- **Pure Rust decoders**: FT8/FT4/FT2, APRS, CW, WSPR, AIS, VDES, RDS -- all implemented without C FFI dependencies. Consistent decoder pattern: stateful struct → `process_block()` → `decode_if_ready()`.
- **Good test coverage** in protocol layer: codec, mapping, auth all have thorough unit tests with round-trip verification. 45+ mapping tests cover all command variants.
- **Feature-gated backends**: ft817, ft450d, soapysdr compiled conditionally to minimize binary size. Factory pattern with name normalization for registration.
- **Defensive error handling**: Lock poisoning recovery, stream error deduplication with 60s summaries, input truncation in logs (128 chars), per-IP rate limiting on auth endpoints.
- **Well-documented DSP guidelines**: `docs/Optimization-Guidelines.md` captures lessons on NCO design, polyphase resampling, AVX2 batching, and stereo FM decoding.
### Areas for Improvement
All P0P3 items resolved or dropped. See `docs/Improvement-Areas.md` for details.
Generated
+352 -1050
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,3 +1,3 @@
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space> SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
SPDX-License-Identifier: GPL-2.0-or-later SPDX-License-Identifier: BSD-2-Clause
+3 -6
View File
@@ -1,18 +1,16 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space> # SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
# #
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: BSD-2-Clause
[workspace] [workspace]
members = [ members = [
"src/decoders/trx-ais", "src/decoders/trx-ais",
"src/decoders/trx-wxsat",
"src/decoders/trx-aprs", "src/decoders/trx-aprs",
"src/decoders/trx-cw", "src/decoders/trx-cw",
"src/decoders/trx-decode-log", "src/decoders/trx-decode-log",
"src/decoders/trx-ftx", "src/decoders/trx-ft8",
"src/decoders/trx-rds", "src/decoders/trx-rds",
"src/decoders/trx-vdes", "src/decoders/trx-vdes",
"src/decoders/trx-wefax",
"src/decoders/trx-wspr", "src/decoders/trx-wspr",
"src/trx-core", "src/trx-core",
"src/trx-protocol", "src/trx-protocol",
@@ -28,7 +26,6 @@ members = [
"src/trx-client/trx-frontend/trx-frontend-http", "src/trx-client/trx-frontend/trx-frontend-http",
"src/trx-client/trx-frontend/trx-frontend-http-json", "src/trx-client/trx-frontend/trx-frontend-http-json",
"src/trx-client/trx-frontend/trx-frontend-rigctl", "src/trx-client/trx-frontend/trx-frontend-rigctl",
"src/trx-configurator",
] ]
resolver = "2" resolver = "2"
-338
View File
@@ -1,338 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
-43
View File
@@ -1,43 +0,0 @@
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting — in part or in whole — any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,4 +1,4 @@
# DSP Optimization Guidelines # DSP Chain Performance Optimization Guidelines
This document captures lessons learned and best practices for optimizing This document captures lessons learned and best practices for optimizing
the real-time DSP pipelines in trx-rs, particularly the WFM stereo decoder the real-time DSP pipelines in trx-rs, particularly the WFM stereo decoder
+68 -119
View File
@@ -1,4 +1,4 @@
# trx-rs Architecture # trx-rs Code Design & Architecture Overview
## Table of Contents ## Table of Contents
@@ -52,34 +52,42 @@ Target users are amateur radio operators who want networked, automated, or multi
| CAT serial | tokio-serial | | CAT serial | tokio-serial |
| CLI | clap | | CLI | clap |
| Logging | tracing / tracing-subscriber | | Logging | tracing / tracing-subscriber |
| FTx decode | trx-ftx (pure Rust) | | FT8 decode | ft8_lib (external C library via FFI) |
--- ---
## High-Level Architecture ## High-Level Architecture
```mermaid ```
graph TD ┌──────────────────────────────────────────────────────────┐
subgraph server["trx-server"] │ trx-server │
HW["Radio Hardware"] <-->|"CAT protocol<br/>serial / TCP"| Backend["Rig Backend<br/>(ft817 / ft450d / sdr)"] │ │
Backend --> RigTask["rig_task.rs<br/>(state machine)"] │ Radio Hardware (serial/TCP) │
RigTask --> Listener["listener.rs<br/>(JSON TCP :4530)"] │ ↕ CAT protocol │
RigTask --> Audio["audio.rs<br/>(Opus :4531)"] │ Rig Backend ──────── rig_task.rs ─── listener.rs │
Audio --> Decoders["Decoders<br/>(APRS, CW, FT8, WSPR, RDS)"] │ (ft817/ft450d/sdr) (state machine) (JSON TCP :4530) │
Decoders --> Uplinks["PSKReporter / APRS-IS"] │ │ │
end │ audio.rs │
│ (Opus :4531) │
subgraph client["trx-client"] │ │ │
Remote["remote_client.rs<br/>(polls state, routes commands)"] │ Decoders │
Remote <-->|"mpsc / watch channels"| HTTP["trx-frontend-http<br/>(Web UI :8080)"] │ (APRS, CW, FT8, WSPR, RDS) │
Remote <-->|"mpsc / watch channels"| Rigctl["trx-frontend-rigctl<br/>(rigctl :4532)"] │ PSKReporter / APRS-IS │
Remote <-->|"mpsc / watch channels"| JSON["trx-frontend-http-json<br/>(JSON/TCP)"] └──────────────────────────────────────────────────────────┘
end ↕ JSON TCP (port 4530)
↕ Opus audio TCP (port 4531)
Listener <-->|"JSON TCP :4530"| Remote ┌──────────────────────────────────────────────────────────┐
Audio -->|"Opus TCP :4531"| Remote │ trx-client │
│ │
HTTP & Rigctl & JSON <--> Users["End Users<br/>(Browser / Hamlib / Custom tools)"] │ remote_client.rs (polls state, routes commands) │
│ ↕ internal mpsc/watch channels │
│ Frontends: │
│ trx-frontend-http (Web UI :8080) │
│ trx-frontend-rigctl (rigctl :4532) │
│ trx-frontend-http-json (JSON/TCP ephemeral) │
└──────────────────────────────────────────────────────────┘
↕ Browser / Hamlib / Custom tools
End Users
``` ```
The server and client are separate binaries. They communicate over **JSON-over-TCP** (control) and **Opus-encoded TCP** (audio). Both binaries can load shared-library plugins at startup. The server and client are separate binaries. They communicate over **JSON-over-TCP** (control) and **Opus-encoded TCP** (audio). Both binaries can load shared-library plugins at startup.
@@ -91,6 +99,7 @@ The server and client are separate binaries. They communicate over **JSON-over-T
``` ```
trx-rs/ # Workspace root trx-rs/ # Workspace root
├── Cargo.toml # Workspace manifest (shared dependencies) ├── Cargo.toml # Workspace manifest (shared dependencies)
├── CLAUDE.md # Contributor notes
└── src/ └── src/
├── trx-core/ # Core types, traits, state machine ├── trx-core/ # Core types, traits, state machine
@@ -135,7 +144,7 @@ trx-rs/ # Workspace root
└── decoders/ └── decoders/
├── trx-aprs/ # APRS packet decoder ├── trx-aprs/ # APRS packet decoder
├── trx-cw/ # CW / Morse decoder ├── trx-cw/ # CW / Morse decoder
├── trx-ftx/ # Pure Rust FTx decoder (FT8/FT4/FT2) ├── trx-ft8/ # FT8 decoder (wraps ft8_lib C library)
├── trx-wspr/ # WSPR beacon decoder ├── trx-wspr/ # WSPR beacon decoder
├── trx-rds/ # FM RDS decoder ├── trx-rds/ # FM RDS decoder
└── trx-decode-log/ # JSON Lines log rotation for decoded frames └── trx-decode-log/ # JSON Lines log rotation for decoded frames
@@ -292,10 +301,10 @@ pub trait RetryPolicy: Send {
} }
pub struct ExponentialBackoff { pub struct ExponentialBackoff {
max_attempts: u32, initial_delay: Duration,
base_delay: Duration,
max_delay: Duration, max_delay: Duration,
// Delays include ±25% randomized jitter to prevent thundering herd multiplier: f64,
current_delay: Duration,
} }
pub trait PollingPolicy: Send { pub trait PollingPolicy: Send {
@@ -508,7 +517,7 @@ impl RegistrationContext {
Built-in registrations (via `register_builtin_backends_on`): Built-in registrations (via `register_builtin_backends_on`):
- `"ft817"``Ft817::new` - `"ft817"``Ft817::new`
- `"ft450d"``Ft450d::new` - `"ft450d"``Ft450d::new`
- `"soapysdr"``SoapySdrRig::new_from_config(SoapySdrConfig { ... })` (if `soapysdr` feature enabled) - `"soapysdr"``SoapySdrRig::new_with_config` (if `soapysdr` feature enabled)
### RigCat Trait (from trx-core) ### RigCat Trait (from trx-core)
@@ -560,6 +569,8 @@ pub struct SoapySdrRig {
} }
``` ```
**Known limitation:** IQ sample streaming (`real_iq_source.rs:149157`) is not yet implemented — the IQ source currently returns zero buffers. The soapysdr 0.3 crate lacks streaming APIs; direct `soapysdr-sys` FFI or a crate upgrade would be required.
--- ---
## Client (trx-client) ## Client (trx-client)
@@ -647,8 +658,32 @@ Built on **Actix-web**, serves a browser-based control panel.
| GET | `/audio` | WebSocket audio stream | | GET | `/audio` | WebSocket audio stream |
| GET | `/favicon.png` | Static asset | | GET | `/favicon.png` | Static asset |
`/status` response includes a `FrontendMeta` block:
```rust
struct FrontendMeta {
http_clients: usize,
rigctl_clients: usize,
rigctl_addr: Option<String>,
active_rig_id: Option<String>,
rig_ids: Vec<String>,
owner_callsign: Option<String>,
show_sdr_gain_control: bool,
}
```
**Web UI features:** frequency display/entry, mode selector, PTT indicator, S-meter/TX-power/SWR meters, decoder toggles, decode history, spectrum waterfall (SDR), rig picker (multi-rig). **Web UI features:** frequency display/entry, mode selector, PTT indicator, S-meter/TX-power/SWR meters, decoder toggles, decode history, spectrum waterfall (SDR), rig picker (multi-rig).
**Modules:**
| File | Responsibility |
|------|---------------|
| `server.rs` | Actix app builder, middleware, CORS |
| `api.rs` | REST handler functions |
| `audio.rs` | WebSocket ↔ PCM audio bridge |
| `auth.rs` | Token or basic-auth middleware |
| `status.rs` | State formatting for JSON responses |
### Rigctl Frontend (`trx-frontend-rigctl/`) ### Rigctl Frontend (`trx-frontend-rigctl/`)
Hamlib-compatible plaintext TCP interface on port 4532. Allows WSJT-X, JS8Call, and other Hamlib-aware applications to control the rig without modification. Hamlib-compatible plaintext TCP interface on port 4532. Allows WSJT-X, JS8Call, and other Hamlib-aware applications to control the rig without modification.
@@ -669,14 +704,14 @@ All decoders run as background Tokio tasks inside `trx-server`. They subscribe t
|-------|---------|-------| |-------|---------|-------|
| `trx-aprs` | APRS (AX.25) | Forwards to APRS-IS if enabled | | `trx-aprs` | APRS (AX.25) | Forwards to APRS-IS if enabled |
| `trx-cw` | CW / Morse | Auto WPM detection | | `trx-cw` | CW / Morse | Auto WPM detection |
| `trx-ftx` | FTx | Pure Rust FT8/FT4/FT2 decoder; posts to PSKReporter | | `trx-ft8` | FT8 | Wraps `external/ft8_lib` C library via FFI; posts to PSKReporter |
| `trx-wspr` | WSPR beacons | Posts to PSKReporter | | `trx-wspr` | WSPR beacons | Posts to PSKReporter |
| `trx-rds` | FM RDS | Station name, radiotext, time | | `trx-rds` | FM RDS | Station name, radiotext, time |
| `trx-decode-log` | Logging infrastructure | JSON Lines, date-rotated files | | `trx-decode-log` | Logging infrastructure | JSON Lines, date-rotated files |
Control commands (e.g., `SetAprsDecodeEnabled(bool)`, `ResetCwDecoder`) are routed through `rig_task.rs` to the active decoder tasks. Control commands (e.g., `SetAprsDecodeEnabled(bool)`, `ResetCwDecoder`) are routed through `rig_task.rs` to the active decoder tasks.
Decoded events are multiplexed onto the audio stream wire protocol (`0x03``0x06` frame types) and also buffered in `DecoderHistories` for replay to newly connected clients. Decoded events are multiplexed onto the audio stream wire protocol (`0x030x06` frame types) and also buffered in `DecoderHistories` for replay to newly connected clients.
--- ---
@@ -711,7 +746,7 @@ Decoders / Audio server
WFM demodulator supports: WFM demodulator supports:
- Stereo pilot detection and L+R/LR matrix decoding - Stereo pilot detection and L+R/LR matrix decoding
- Configurable de-emphasis time constant (50 us EU / 75 us US) - Configurable de-emphasis time constant (50 µs EU / 75 µs US)
- Optional noise reduction - Optional noise reduction
### Spectrum (`spectrum.rs`) ### Spectrum (`spectrum.rs`)
@@ -998,90 +1033,4 @@ stream decoder messages HTTP WebSocket / local speakers
--- ---
## Detailed Component Notes *Generated from source as of commit `56d6d12` (March 2026).*
### Rig Task Internals (`rig_task.rs` — 1,315 lines)
The rig task is the heart of the server. Key implementation details:
- **Command batching**: Accumulates pending requests before processing sequentially in FIFO order.
- **Spectrum deduplication**: Concurrent `GetSpectrum` requests are collapsed — one DSP computation broadcasts to all waiting responders.
- **Adaptive polling**: Poll interval adjusts based on TX state (100ms during TX, 500ms idle).
- **Grace period**: 800ms pause on polling after power-on/off operations to let hardware settle.
- **VFO priming**: Optional initialization sequence that toggles VFO A/B to populate the state cache.
- **Per-rig decoder histories**: Each rig maintains independent `Arc<DecoderHistories>` for all 11 decoder types.
- **Configurable timeouts**: `command_exec_timeout` (default 10s) and `poll_refresh_timeout` (default 8s) are configurable via `RigTaskConfig` and the TOML `[timeouts]` section.
- **Crash recovery**: Rig tasks are monitored; on crash, an `Error` state is broadcast to clients via the watch channel so they see the failure instead of silent timeout.
### Audio Pipeline (`audio.rs` — 3,977 lines)
The audio module handles decoder history storage and stream management:
- **`DecoderHistories`**: Per-rig mutable store for 11 decoder history queues (AIS, VDES, APRS, HF_APRS, CW, FT8, FT4, FT2, WSPR, WXSAT, LRPT).
- **Time-based retention**: 24h TTL on all history with periodic pruning.
- **Capacity bounds**: Per-decoder max of 10,000 entries (`MAX_HISTORY_ENTRIES`) prevents unbounded memory growth on busy channels.
- **Atomic total count**: `AtomicUsize` with CAS loop avoids acquiring 11 mutex locks in `snapshot_all()`.
- **Lock poisoning recovery with logging**: Uses `lock_or_recover()` helper that logs a warning when recovering from a poisoned mutex.
- **`StreamErrorLogger`**: Suppresses duplicate stream errors with 60s periodic summaries and error classification (alsa_poll_failure, input/output_stream_error).
- **Device enumeration helpers**: `find_input_device()` and `find_output_device()` extract the repeated device lookup logic from `run_capture()`/`run_playback()`.
- **CRC filtering**: APRS records filtered by `crc_ok` before storage.
### Remote Client Dual-Connection Model
`remote_client.rs` maintains two independent TCP connections to the server:
1. **Main connection** (port 4530): State polling, command forwarding, rig discovery.
2. **Spectrum connection** (dedicated): Polls `GetSpectrum` at 50ms intervals (20 fps) independently to avoid blocking the main connection during command processing.
Constants: `CONNECT_TIMEOUT: 5s`, `IO_TIMEOUT: 15s`, `SPECTRUM_IO_TIMEOUT: 3s`. Exponential backoff with jitter on reconnect.
### FrontendRuntimeContext Sub-Structs
The `FrontendRuntimeContext` struct in `trx-frontend/src/lib.rs` is decomposed into coherent sub-structs:
| Sub-struct | Purpose | Key fields |
|-----------|---------|------------|
| `AudioContext` | Audio streaming channels | `rx`, `tx`, `info`, `decode_rx`, `clients` |
| `DecodeHistoryContext` | Decode history for all types | `ais`, `vdes`, `aprs`, `hf_aprs`, `cw`, `ft8`, `ft4`, `ft2`, `wspr` |
| `HttpAuthConfig` | HTTP auth settings | `enabled`, `rx_passphrase`, `session_ttl_secs`, `tokens` |
| `HttpUiConfig` | HTTP UI display config | `show_sdr_gain_control`, `initial_map_zoom`, `spectrum_*` |
| `RigRoutingContext` | Remote rig state & routing | `active_rig_id`, `remote_rigs`, `rig_states`, `server_connected` |
| `OwnerInfo` | Station metadata | `callsign`, `website_url`, `ais_vessel_url_base` |
| `VChanContext` | Virtual channel audio | `audio`, `audio_cmd`, `destroyed`, `rig_audio_cmd` |
| `SpectrumContext` | Spectrum data | `sender`, `per_rig` |
| `PerRigAudioContext` | Per-rig audio channels | `rx`, `info` |
### Decoder Implementation Patterns
All real-time decoders follow a consistent pattern:
```rust
// 1. Stateful decoder struct with sample buffer
pub struct XxxDecoder { sample_buf: Vec<f32>, ... }
// 2. Block/sample processing
pub fn process_block(&mut self, samples: &[f32]) { ... }
// 3. Result extraction
pub fn decode_if_ready(&mut self) -> Vec<XxxResult> { ... }
```
| Decoder | Algorithm | Sample Rate | Key Constants |
|---------|-----------|-------------|---------------|
| FT8/FT4/FT2 | Waterfall + LDPC/OSD | Varies | MAX_LDPC_ITERATIONS=20, MAX_CANDIDATES=120 |
| CW | Goertzel tone detection | Varies | 10ms windows, tone range 3001200 Hz |
| APRS | Bell 202 AFSK (1200/2200 Hz) | 9600 | HDLC framing, NRZI, CRC-16-CCITT |
| AIS | GMSK 9600 baud | 9600 | Narrowband FM input |
| WSPR | Fano decoder | 12000 | 162 symbols, 120s slot, 1.46 Hz spacing |
| RDS | RRC matched filter + Costas PLL | Native | 57 kHz subcarrier, 1187.5 bps, OSD FEC |
| VDES | pi/4-QPSK 76.8 ksps | 100k | Burst detection, partial Turbo FEC |
### Backend Reliability Workarounds (FT-817)
The FT-817 CAT backend (`trx-backend-ft817/`) includes empirical workarounds for hardware quirks:
- **Duplicate frame sends**: `set_mode()` and `set_ptt()` send CAT frames twice with 80ms delay (radio sometimes drops first frame).
- **Panel unlock before commands**: Clears stale bytes from the serial buffer.
- **Power-on dummy frame**: CPU wakes before CAT framing locks; dummy frame ensures readiness.
- **VFO state inference**: Infers VFO A/B by matching frequencies against cached values (fragile when frequencies collide).
- **Read timeout**: 800ms per CAT read operation (not configurable).
+141 -100
View File
@@ -1,147 +1,188 @@
<div align="center"> <div align="center">
<img src="assets/trx-logo.png" alt="trx-rs logo" width="25%" /> <img src="assets/trx-logo.png" alt="trx-rs logo" width="25%" />
</div>
# trx-rs # trx-rs
A modular amateur radio control stack written in Rust. `trx-rs` is a modular amateur radio control stack written in Rust.
It splits radio hardware access from user-facing interfaces so you can run
[![License](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSES)
</div>
`trx-rs` splits radio hardware access from user-facing interfaces so you can run
rig control, SDR DSP, decoding, audio streaming, and web access as separate, rig control, SDR DSP, decoding, audio streaming, and web access as separate,
composable pieces. composable pieces.
| | | The project is built around two primary binaries:
|---|---|
| **Backends** | Yaesu FT-817, Yaesu FT-450D, SoapySDR | - `trx-server`: talks to radios and SDR backends
| **Frontends** | Web UI, rigctl-compatible TCP, JSON-over-TCP | - `trx-client`: connects to the server and exposes frontends such as the web UI
| **Decoders** | AIS, APRS, CW, FT8, RDS, VDES, WSPR |
| **Audio** | Opus streaming between server, client, and browser | ## Web UI Demo
> GIF placeholder: add an animated walkthrough of the website here.
## What It Does
- Controls supported radios over networked client/server boundaries
- Exposes a browser UI, a rigctl-compatible frontend, and JSON-based control
- Supports SDR workflows with live spectrum, waterfall, demodulation, and decode
- Streams Opus audio between server, client, and browser
- Runs multiple decoders including AIS, APRS, CW, FT8, RDS, VDES, and WSPR
- Supports multi-rig deployments and SDR virtual channels
- Loads backends and frontends via plugins
## Architecture
At a high level:
1. `trx-server` owns the radio hardware and DSP pipeline.
2. `trx-client` connects to the server over TCP for control and audio.
3. Frontends hang off `trx-client`, including the HTTP web UI.
This separation is intentional: it keeps hardware access local to one host while
making control and monitoring available elsewhere on the network.
## Workspace Layout
- `src/trx-core`: shared types, rig state, controller logic
- `src/trx-protocol`: client/server protocol types and codecs
- `src/trx-app`: shared app bootstrapping, config, logging, plugins
- `src/trx-server`: server binary and backend integration
- `src/trx-client`: client binary and remote connection handling
- `src/trx-client/trx-frontend`: frontend abstraction
- `src/decoders`: protocol-specific decoder crates
- `examples/trx-plugin-example`: minimal plugin example
## Supported Pieces
### Backends
- Yaesu FT-817
- Yaesu FT-450D
- SoapySDR-based SDR backend
### Frontends
- HTTP web frontend
- rigctl-compatible TCP frontend
- JSON-over-TCP frontend
### Decoders
- AIS
- APRS
- CW
- FT8
- RDS
- VDES
- WSPR
## Build Requirements
You will need Rust plus a few system libraries.
### Common dependencies
- `libopus`
- `pkg-config` or `pkgconf`
- `cmake`
### SDR builds
- `libsoapysdr`
### Audio builds
- Core Audio on macOS, or ALSA development packages on Linux
## Configuration
Both `trx-server` and `trx-client` read from a shared `trx-rs.toml`.
- Default lookup order: current directory, `~/.config/trx-rs`, `/etc/trx-rs`
- Use `--config <FILE>` to point at an explicit config file
- Use `--print-config` to print an example combined config
Start from [`trx-rs.toml.example`](trx-rs.toml.example).
## Quick Start ## Quick Start
### 1. Install dependencies ### 1. Build
<details>
<summary><b>Debian / Ubuntu</b></summary>
```bash ```bash
sudo apt install build-essential pkg-config cmake libopus-dev libasound2-dev cargo build
# Optional — SDR support
sudo apt install libsoapysdr-dev
``` ```
</details>
<details> ### 2. Create a config file
<summary><b>Fedora</b></summary>
```bash ```bash
sudo dnf install gcc pkg-config cmake opus-devel alsa-lib-devel cp trx-rs.toml.example trx-rs.toml
# Optional — SDR support
sudo dnf install SoapySDR-devel
``` ```
</details>
<details> Adjust backend, frontend, audio, and auth settings for your environment.
<summary><b>Arch Linux</b></summary>
### 3. Run the server
```bash ```bash
sudo pacman -S base-devel pkgconf cmake opus alsa-lib cargo run -p trx-server
# Optional — SDR support
sudo pacman -S soapysdr
``` ```
</details>
<details> ### 4. Run the client
<summary><b>macOS (Homebrew)</b></summary>
```bash ```bash
brew install cmake opus cargo run -p trx-client
# Optional — SDR support
brew install soapysdr
```
</details>
See [Build Requirements](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual#build-requirements)
in the wiki for details on each library.
> **Note:** `cmake` is required even when a system Opus library is installed.
> The `audiopus_sys` crate probes for Opus via `pkg-config`; if it is not found
> (or `pkg-config` is unavailable), it falls back to compiling a vendored copy
> of Opus with CMake. A missing `cmake` therefore fails the build with
> `is cmake not installed?` rather than a missing-Opus error.
### 2. Build
```bash
cargo build --release
``` ```
Build without SDR support: `cargo build --release --no-default-features` ### 5. Open the web UI
### 3. Configure Open the configured HTTP frontend address in a browser.
Run the interactive setup wizard to generate config files for your station: ## Web Frontend Highlights
```bash - Real-time spectrum and waterfall
./target/release/trx-configurator - Frequency, mode, and bandwidth control
``` - Decoder dashboards and history
- SDR virtual channels
- Browser RX/TX audio
- Optional authentication with read-only and control roles
The wizard walks you through rig selection, serial port detection, audio ## Authentication
settings, and frontend options, then writes `trx-server.toml` and
`trx-client.toml`.
Alternatively, generate example configs and edit them by hand: The HTTP frontend supports optional passphrase-based authentication.
```bash - `rx`: read-only access
./target/release/trx-server --print-config > trx-server.toml - `control`: full control access
./target/release/trx-client --print-config > trx-client.toml
```
### 4. Run When exposing the web UI beyond a trusted LAN, run it behind HTTPS and enable
secure cookie settings in the config.
```bash ## Audio
./target/release/trx-server --config trx-server.toml
./target/release/trx-client --config trx-client.toml
```
Open the configured HTTP frontend address in a browser (default `http://localhost:8080`). Audio is transported as Opus between server, client, and browser.
## How It Works - `trx-server` captures and encodes audio
- `trx-client` relays audio to the HTTP frontend
- Browsers connect over `/audio`
```mermaid ## Plugins
graph TD
SDR1["SDR #1"] & SDR2["SDR #2"] <-->|USB| S1["trx-server A"]
SDR3["SDR #3"] & FT817["FT-817"] <-->|USB / serial| S2["trx-server B"]
S1 <-->|"JSON-TCP :4530"| C1["trx-client"] Both binaries can discover shared-library plugins through:
S1 -->|"Opus-TCP per rig"| C1
S2 <-->|"JSON-TCP :4530"| C1
S2 -->|"Opus-TCP per rig"| C1
C1 <-->|internal channels| F1["Web UI :8080"] - `./plugins`
C1 <-->|internal channels| F2["rigctl :4532"] - `~/.config/trx-rs/plugins`
``` - `TRX_PLUGIN_DIRS`
Each `trx-server` owns one or more rigs and runs DSP, decoding, and audio capture locally. See [`examples/trx-plugin-example/README.md`](examples/trx-plugin-example/README.md).
A `trx-client` connects to any number of servers over TCP and exposes them through
a unified set of frontends.
## Documentation ## Documentation
| Resource | Description | - [`OVERVIEW.md`](OVERVIEW.md): architecture and design overview
|----------|-------------| - [`CONTRIBUTING.md`](CONTRIBUTING.md): contribution and commit rules
| [User Manual](https://git.haxx.space/sjg/trx-rs/wiki/User-Manual) | Configuration, features, and usage |
| [Architecture](https://git.haxx.space/sjg/trx-rs/wiki/Architecture) | System design, crate layout, data flow, and internals | ## Project Status
| [Optimization Guidelines](https://git.haxx.space/sjg/trx-rs/wiki/Optimization-Guidelines) | Performance guidelines for the real-time DSP pipeline |
| [Planned Features](https://git.haxx.space/sjg/trx-rs/wiki/Planned-Features) | Roadmap and design notes | This is an active project with evolving APIs and frontend behavior. Expect some
| [Contributing](CONTRIBUTING.md) | Commit conventions, workflow, and code style | rough edges and ongoing refactors.
## License ## License
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and Licensed under BSD-2-Clause.
bundled third-party license files. Bundled third-party components (Leaflet and
the Leaflet AIS tracksymbol plugin under `assets/web/vendor/`) retain their See [`LICENSES`](LICENSES) for bundled third-party license files.
original BSD-2-Clause license.
+22 -119
View File
@@ -1,10 +1,10 @@
# Planned Features # Recorder Feature Plan
## Recorder ## Overview
The recorder captures the demodulated audio stream alongside associated metadata (FFT data, decoded signals, rig state) into a structured session on disk, with full playback and seeking support from within the application. This document describes the design and implementation plan for the recorder feature in trx-rs. The recorder captures the demodulated audio stream alongside associated metadata (FFT data, decoded signals, rig state) into a structured session on disk, with full playback and seeking support from within the application.
### Requirements ## Requirements
| ID | Description | | ID | Description |
|----|-------------| |----|-------------|
@@ -20,9 +20,9 @@ The recorder captures the demodulated audio stream alongside associated metadata
--- ---
### Architecture ## Architecture
#### New Crate: `trx-recorder` ### New Crate: `trx-recorder`
A new crate `src/trx-server/trx-recorder/` handles all record and playback logic. It is a library crate consumed by `trx-server`. A new crate `src/trx-server/trx-recorder/` handles all record and playback logic. It is a library crate consumed by `trx-server`.
@@ -39,7 +39,7 @@ src/trx-server/
config.rs # RecorderConfig (serde, derives Default) config.rs # RecorderConfig (serde, derives Default)
``` ```
#### Integration Points in `trx-server` ### Integration Points in `trx-server`
| Source | What is tapped | How | | Source | What is tapped | How |
|--------|---------------|-----| |--------|---------------|-----|
@@ -56,7 +56,7 @@ No existing code paths are modified beyond:
--- ---
### Session Layout on Disk ## Session Layout on Disk
Each recording is a **session directory** named by UTC start time and opening rig state: Each recording is a **session directory** named by UTC start time and opening rig state:
@@ -70,13 +70,13 @@ Each recording is a **session directory** named by UTC start time and opening ri
`output_dir` defaults to `~/.local/share/trx-rs/recordings`. `output_dir` defaults to `~/.local/share/trx-rs/recordings`.
#### Audio File (REQ-REC-001, REQ-REC-002, REQ-REC-003) ### Audio File (REQ-REC-001, REQ-REC-002, REQ-REC-003)
- **Format**: Opus, using the `opus` crate (already a workspace dependency via `trx-backend-soapysdr`). Seek index (`index.bin`) provides byte → time mapping. - **Format**: Opus, using the `opus` crate (already a workspace dependency via `trx-backend-soapysdr`). Seek index (`index.bin`) provides byte → time mapping.
- **Channel count**: determined at session open from `AudioConfig.channels`. If `channels == 1` → mono; if `channels == 2` → stereo. Written into the file header and recorded in the session's first data event. - **Channel count**: determined at session open from `AudioConfig.channels`. If `channels == 1` → mono; if `channels == 2` → stereo. Written into the file header and recorded in the session's first data event.
- **Sample rate**: preserved from `AudioConfig.sample_rate` (default 48 000 Hz). - **Sample rate**: preserved from `AudioConfig.sample_rate` (default 48 000 Hz).
#### Data File (REQ-REC-004, REQ-REC-005) ### Data File (REQ-REC-004, REQ-REC-005)
`data.jsonl` — one JSON object per line, each with a required `offset_ms` field giving the millisecond offset from session start (satisfies REQ-SYNC-001 at ≥1 s resolution): `data.jsonl` — one JSON object per line, each with a required `offset_ms` field giving the millisecond offset from session start (satisfies REQ-SYNC-001 at ≥1 s resolution):
@@ -103,7 +103,7 @@ Supported `type` values:
| `cw` | `DecodedMessage` broadcast | on decode event | | `cw` | `DecodedMessage` broadcast | on decode event |
| `cursor` | `RecorderCommand::MarkCursor { label }` | on user request | | `cursor` | `RecorderCommand::MarkCursor { label }` | on user request |
#### Seek Index (REQ-PLAY-002) ### Seek Index (REQ-PLAY-002)
`index.bin` is a flat binary table of 16-byte records written every `index_interval_ms` (default 1 000 ms): `index.bin` is a flat binary table of 16-byte records written every `index_interval_ms` (default 1 000 ms):
@@ -115,7 +115,7 @@ At playback seek time, binary search on `offset_ms` locates the nearest audio fr
--- ---
### RecorderConfig ## RecorderConfig
Added to `ServerConfig` under `[recorder]`: Added to `ServerConfig` under `[recorder]`:
@@ -131,7 +131,7 @@ max_session_duration_s = 3600 # auto-split at 1 h; 0 = unlimited
--- ---
### Command API ## Command API
New variants added to the existing command enum (handled in `rig_task.rs`): New variants added to the existing command enum (handled in `rig_task.rs`):
@@ -147,7 +147,7 @@ These are exposed via:
--- ---
### Playback Engine (REQ-PLAY-001, REQ-PLAY-002) ## Playback Engine (REQ-PLAY-001, REQ-PLAY-002)
`PlaybackEngine` opens a session directory and: `PlaybackEngine` opens a session directory and:
@@ -170,7 +170,7 @@ While `PlaybackState` is not `Live`, the server suppresses live hardware polling
--- ---
### Time Synchronisation (REQ-SYNC-001) ## Time Synchronisation (REQ-SYNC-001)
All timestamps use a single `session_epoch: std::time::Instant` captured at `StartRecording`. Every PCM frame, every data event, and every seek-index entry is stamped as `(Instant::now() - session_epoch).as_millis() as u64`. This gives sub-millisecond internal precision; the requirement of ≥1 s resolution is met by orders of magnitude. All timestamps use a single `session_epoch: std::time::Instant` captured at `StartRecording`. Every PCM frame, every data event, and every seek-index entry is stamped as `(Instant::now() - session_epoch).as_millis() as u64`. This gives sub-millisecond internal precision; the requirement of ≥1 s resolution is met by orders of magnitude.
@@ -178,16 +178,16 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
--- ---
### Implementation Phases ## Implementation Phases
#### Phase 1 — Audio recording (REQ-REC-001, REQ-REC-002, REQ-REC-003) ### Phase 1 — Audio recording (REQ-REC-001, REQ-REC-002, REQ-REC-003)
1. Add `trx-recorder` crate skeleton; `RecorderConfig`; `RecorderHandle`. 1. Add `trx-recorder` crate skeleton; `RecorderConfig`; `RecorderHandle`.
2. Implement `AudioWriter` with Opus output. 2. Implement `AudioWriter` with Opus output.
3. Subscribe `AudioWriter` to `pcm_tx` in `audio.rs`; open session on `StartRecording` command. 3. Subscribe `AudioWriter` to `pcm_tx` in `audio.rs`; open session on `StartRecording` command.
4. Auto-detect channel count from `AudioConfig.channels`. 4. Auto-detect channel count from `AudioConfig.channels`.
#### Phase 2 — Metadata recording (REQ-REC-004, REQ-REC-005, REQ-SYNC-001) ### Phase 2 — Metadata recording (REQ-REC-004, REQ-REC-005, REQ-SYNC-001)
1. Implement `DataFileWriter`; define full event schema. 1. Implement `DataFileWriter`; define full event schema.
2. Subscribe to `DecodedMessage` broadcast; fan-in all decoder types. 2. Subscribe to `DecodedMessage` broadcast; fan-in all decoder types.
@@ -195,12 +195,12 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
4. Emit `fft` events at configured interval from spectrum data. 4. Emit `fft` events at configured interval from spectrum data.
5. Write `SeekIndex` in parallel with audio. 5. Write `SeekIndex` in parallel with audio.
#### Phase 3 — Cursor (REQ-REC-006) ### Phase 3 — Cursor (REQ-REC-006)
1. Add `MarkCursor` command + HTTP endpoint. 1. Add `MarkCursor` command + HTTP endpoint.
2. Write `cursor` event to `data.jsonl` with current `offset_ms`. 2. Write `cursor` event to `data.jsonl` with current `offset_ms`.
#### Phase 4 — Playback (REQ-PLAY-001, REQ-PLAY-002) ### Phase 4 — Playback (REQ-PLAY-001, REQ-PLAY-002)
1. Implement `PlaybackEngine`; Opus decode + PCM broadcast. 1. Implement `PlaybackEngine`; Opus decode + PCM broadcast.
2. Add `PlaybackState` to `RigState`; suppress live capture during playback. 2. Add `PlaybackState` to `RigState`; suppress live capture during playback.
@@ -210,7 +210,7 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
--- ---
### Dependencies to Add ## Dependencies to Add
| Crate | Use | Already present? | | Crate | Use | Already present? |
|-------|-----|-----------------| |-------|-----|-----------------|
@@ -220,105 +220,8 @@ Wall-clock UTC is embedded only in `session_start` (`wall_clock_utc`) and in the
--- ---
### Open Questions ## Open Questions
1. **Playback isolation**: Should playback be exclusive (block all CAT commands) or concurrent? Initial design blocks CAT polling; revisit if users need to change frequency during playback. 1. **Playback isolation**: Should playback be exclusive (block all CAT commands) or concurrent? Initial design blocks CAT polling; revisit if users need to change frequency during playback.
2. **Session listing API**: The HTTP frontend needs an endpoint to enumerate sessions (`GET /api/recorder/sessions`). Schema TBD in Phase 4. 2. **Session listing API**: The HTTP frontend needs an endpoint to enumerate sessions (`GET /api/recorder/sessions`). Schema TBD in Phase 4.
3. **Storage limits**: `max_session_duration_s` auto-splits sessions; a `max_total_size_gb` housekeeping option may be needed but is out of scope for initial phases. 3. **Storage limits**: `max_session_duration_s` auto-splits sessions; a `max_total_size_gb` housekeeping option may be needed but is out of scope for initial phases.
---
## Configurator Helper
An interactive CLI tool that guides users through creating configuration files
for trx-rs. Instead of editing TOML by hand, the user answers prompts and the
tool generates valid, commented configuration files.
### Overview
The configurator is a standalone Rust binary (`trx-configurator`) that reuses
the existing config structs from `trx-app`, `trx-server`, and `trx-client`. It
walks the user through a question-driven flow, validates inputs against the same
rules the binaries use at startup, and writes one or more of:
- `trx-server.toml` — server configuration
- `trx-client.toml` — client configuration
- `trx-rs.toml` — combined server + client configuration
The user chooses which file(s) to generate.
### Requirements
| ID | Description |
|----|-------------|
| REQ-CFG-001 | The tool shall interactively prompt the user for configuration values. |
| REQ-CFG-002 | The tool shall generate `trx-server.toml`, `trx-client.toml`, or `trx-rs.toml` per user selection. |
| REQ-CFG-003 | The tool shall validate all inputs using the same validation logic as the server and client binaries. |
| REQ-CFG-004 | The tool shall write commented TOML with descriptions of each field. |
| REQ-CFG-005 | The tool shall detect connected serial devices and offer them for rig access configuration. |
| REQ-CFG-006 | The tool shall detect available SoapySDR devices and offer them for SDR backend configuration. |
| REQ-CFG-007 | The tool shall support a non-interactive mode that generates a default config file. |
| REQ-CFG-008 | The tool shall not overwrite existing files without confirmation. |
### Architecture
#### New Crate: `trx-configurator`
A new binary crate at `src/trx-configurator/` that depends on `trx-app` for
config types and validation.
```
src/trx-configurator/
src/
main.rs # CLI entry point, mode selection
prompts.rs # Interactive prompt helpers (with defaults, validation)
detect.rs # Hardware detection (serial ports, SoapySDR devices)
writer.rs # TOML serialisation with inline comments
```
#### Flow
```
trx-configurator
├── What would you like to generate?
│ [ ] trx-server.toml
│ [ ] trx-client.toml
│ [ ] trx-rs.toml (combined)
├── (if server)
│ ├── General: callsign, location
│ ├── Rig: model selection, access (serial/tcp/sdr)
│ │ └── detect serial ports / SoapySDR devices
│ ├── Listen: address, port
│ ├── Audio: sample rate, channels, codec settings
│ ├── SDR: (if soapysdr selected) gain, channels, decoders
│ ├── Uplinks: PSKReporter, APRS-IS
│ └── Decode logs: enable, directory
├── (if client)
│ ├── Remote: server URL, auth token
│ ├── Frontends: HTTP, rigctl, http-json (enable/disable, ports)
│ └── Audio: bridge settings
└── Write file(s) with confirmation
```
#### Hardware Detection
- **Serial ports**: enumerate available serial devices using `serialport` crate
(already a transitive dependency). Present as selectable list with device
path and description.
- **SoapySDR devices**: if built with `soapysdr` feature, call
`SoapySDR::enumerate("")` to list available SDR hardware. Present device
driver, label, and serial number.
#### Dependencies
| Crate | Use | Already present? |
|-------|-----|-----------------|
| `dialoguer` | Interactive prompts, selection, confirmation | No |
| `toml_edit` | TOML serialisation preserving comments | No |
| `trx-app` | Config types and validation | Yes |
| `serialport` | Serial port enumeration | Yes (transitive) |
| `soapysdr` | SDR device enumeration (optional) | Yes (feature-gated) |
-43
View File
@@ -1,43 +0,0 @@
version = 1
# Project-owned files without an in-file SPDX header (docs, config,
# repo metadata, logos, and bespoke web assets).
[[annotations]]
path = [
".gitattributes",
".gitignore",
"CLAUDE.md",
"CONTRIBUTING.md",
"README.md",
"trx-rs.toml.example",
"docs/**",
"src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js",
]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
# Vendored Leaflet 1.9.4 (https://leafletjs.com), distributed under BSD-2-Clause.
[[annotations]]
path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.js",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/leaflet.css",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/layers-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-icon-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/marker-shadow.png",
]
SPDX-FileCopyrightText = "2010-2023 Vladimir Agafonkin, 2010-2011 CloudMade"
SPDX-License-Identifier = "BSD-2-Clause"
# Vendored DSEG14 font (https://github.com/keshikan/DSEG), SIL OFL 1.1.
[[annotations]]
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/dseg14-classic-latin-400-normal.woff2"]
SPDX-FileCopyrightText = "2020 The DSEG Authors (https://github.com/keshikan/DSEG)"
SPDX-License-Identifier = "OFL-1.1"
+119
View File
@@ -0,0 +1,119 @@
# Background Decoding Scheduler
## Overview
The Background Decoding Scheduler automatically retunes the rig to pre-configured
bookmarks when no users are connected to the HTTP frontend. It runs as a background
tokio task inside `trx-frontend-http`, polling every 30 seconds.
## Modes
### Disabled (default)
Scheduler is inactive. Rig is not touched automatically.
### Grayline
Retunes around the solar terminator (day/night boundary).
The user provides:
- Station latitude and longitude (decimal degrees)
- Optional transition window width (minutes, default 20)
- Bookmark IDs for four periods:
- **Dawn** window around sunrise (`sunrise ± window_min/2`)
- **Day** after dawn until dusk
- **Dusk** window around sunset (`sunset ± window_min/2`)
- **Night** after dusk until next dawn
Period precedence (most specific wins): Dawn > Dusk > Day > Night.
If no bookmark is assigned to a period, the rig is not retuned for that period.
Sunrise/sunset is computed inline using the NOAA simplified algorithm.
Polar regions (midnight sun / polar night) fall back to Day/Night accordingly.
### TimeSpan
Retunes according to a list of user-defined time windows (UTC).
Each entry specifies:
- `start_hhmm` start of window (e.g. 600 = 06:00 UTC)
- `end_hhmm` end of window (e.g. 700 = 07:00 UTC)
- `bookmark_id` bookmark to apply
- `label` optional human-readable description
Windows that span midnight (`end_hhmm < start_hhmm`) are supported.
When multiple entries overlap, the first match (by list order) wins.
## Storage
Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`.
Keys: `sch:{rig_id}` → JSON `SchedulerConfig`.
## HTTP API
All read endpoints are accessible at the **Rx** role level.
Write endpoints require the **Control** role.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig |
| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) |
| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) |
| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event |
## Activation logic
Every 30 seconds the scheduler task checks:
1. `context.sse_clients.load() == 0` — no users connected
2. Active rig has a non-Disabled scheduler config
3. Current UTC time matches a scheduled window or grayline period
4. If the matching bookmark differs from `last_applied`, send `SetFreq` + `SetMode`
The scheduler **does not** revert changes when users reconnect. Bookmarks serve as
a frequency map — the user can retune manually after connecting.
## Data model (Rust)
```rust
pub enum SchedulerMode { Disabled, Grayline, TimeSpan }
pub struct GraylineConfig {
pub lat: f64,
pub lon: f64,
pub transition_window_min: u32,
pub day_bookmark_id: Option<String>,
pub night_bookmark_id: Option<String>,
pub dawn_bookmark_id: Option<String>,
pub dusk_bookmark_id: Option<String>,
}
pub struct ScheduleEntry {
pub id: String,
pub start_hhmm: u32,
pub end_hhmm: u32,
pub bookmark_id: String,
pub label: Option<String>,
}
pub struct SchedulerConfig {
pub rig_id: String,
pub mode: SchedulerMode,
pub grayline: Option<GraylineConfig>,
pub entries: Vec<ScheduleEntry>,
}
```
## UI (Scheduler tab)
A dedicated sixth tab with a clock icon.
- **Rig selector**: shows active rig (read-only).
- **Mode picker**: Disabled / Grayline / TimeSpan radio buttons.
- **Grayline section** (visible when mode = Grayline):
- Lat/lon inputs
- Transition window slider (560 min)
- Four bookmark selectors (Dawn / Day / Dusk / Night)
- **TimeSpan section** (visible when mode = TimeSpan):
- Table of entries with Start, End, Bookmark, Label, Remove button
- "Add Entry" row at the bottom
- **Status card**: last applied bookmark name and timestamp.
- Save button (Control only; form is read-only for Rx users).
+43
View File
@@ -0,0 +1,43 @@
# Repository Guidelines
## Project Structure & Module Organization
- Workspace root contains `Cargo.toml`, `README.md`, and contributor docs.
- Core crates live under `src/`: `src/trx-core`, `src/trx-server`, and `src/trx-client`.
- Server backends are under `src/trx-server/trx-backend` (example: `trx-backend-ft817`).
- Client frontends are under `src/trx-client/trx-frontend` (HTTP, JSON, rigctl).
- Examples live in `examples/` and static assets in `assets/`.
- Reference configs are `trx-server.toml.example` and `trx-client.toml.example`.
## Build, Test, and Development Commands
- `cargo build --release` builds optimized binaries.
- `cargo test` runs the workspace test suite.
- `cargo clippy` runs lint checks.
- Example server run (release build): `./target/release/trx-server -r ft817 "/dev/ttyUSB0 9600"`.
## Coding Style & Naming Conventions
- Rust standard style: 4-space indentation and rustfmt-compatible formatting.
- Naming: `snake_case` for modules/functions, `CamelCase` for types/traits, `SCREAMING_SNAKE_CASE` for constants.
- Prefer small, crate-focused commits; keep changes localized to the relevant crate.
## Testing Guidelines
- Tests are run via `cargo test` across the workspace.
- Add tests near the code they cover (module-level unit tests are preferred).
- If you change behavior in a crate, add or update tests in that crate.
## Commit & Pull Request Guidelines
- Commit title format: `[<type>](<crate>): <description>` (example: `[fix](trx-frontend-http): handle disconnect`).
- Use `(trx-rs)` for repo-wide changes that are not specific to any crate.
- Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`.
- Use imperative mood, keep lines under 80 chars, and separate body with a blank line.
- Sign commits with `git commit -s` and include `Co-authored-by:` for LLM assistance.
- Write isolated commits for each crate.
- Pull requests should include a clear summary, test status, and note any config or runtime changes.
## Contribution Workflow
- Fork the repository and create a new branch for your changes.
- Follow the project's coding style and conventions.
- Ensure changes are tested and pass existing tests.
## Configuration & Plugins
- Configs use TOML. See the example files for required sections and defaults.
- Plugins can be loaded from `./plugins`, `~/.config/trx-rs/plugins`, or `TRX_PLUGIN_DIRS`.
+190
View File
@@ -0,0 +1,190 @@
# HTTP Frontend Authentication Draft
## Goal
Add optional passphrase authentication for `trx-frontend-http` with two roles:
- `rx` passphrase: read-only access
- `control` passphrase: read + control (RX+TX)
API/control routes stay locked until a user logs in from the web UI.
This design keeps current behavior when auth is disabled.
## Scope
- Protect HTTP API endpoints used by the web UI.
- Protect SSE (`/events`, `/decode`) and audio WebSocket (`/audio`).
- Keep static assets and login page accessible so user can authenticate.
- Do not change rigctl/http_json auth behavior in this draft.
## Security Model
- Two optional passphrases configured locally (`rx`, `control`).
- On successful login, server issues short-lived session cookie.
- Session required for all protected routes, with role attached.
- Brute-force mitigation via simple per-IP rate limiting.
- TX access can be globally hidden/blocked unless `control` role is present.
This is not multi-user IAM; it is a pragmatic local/ham-shack gate.
## Config Proposal
Add to `trx-client.toml`:
```toml
[frontends.http.auth]
enabled = false
# Plaintext passphrases (as requested)
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
# If true, TX/PTT controls/endpoints are never available without control auth.
tx_access_control_enabled = true
# Session lifetime in minutes
session_ttl_min = 480
# Cookie security
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
Validation rules:
- If `enabled=false`, all auth fields ignored.
- If `enabled=true`, require at least one passphrase (`rx` and/or `control`).
- `rx_passphrase` only: read-only deployment.
- `control_passphrase` only: control-capable deployment.
- both set: mixed deployment with role split.
Behavior by mode:
- `enabled=false` (default): no authentication, current behavior unchanged.
- `enabled=true`: authentication enforced per role/route rules in this document.
## Runtime Structures
Add in `src/trx-client/trx-frontend/src/lib.rs` (or HTTP crate-local state):
- `HttpAuthConfig`:
- `enabled: bool`
- `rx_passphrase: Option<String>`
- `control_passphrase: Option<String>`
- `tx_access_control_enabled: bool`
- `session_ttl: Duration`
- `cookie_secure: bool`
- `same_site: SameSite`
- `SessionStore` in-memory map:
- key: random session id (128-bit+)
- value: `{ role, issued_at, expires_at, last_seen, ip_hash? }`
Role enum:
- `AuthRole::Rx`
- `AuthRole::Control`
Periodic cleanup task (e.g., every 5 min) removes expired sessions.
## Route Design
New endpoints:
- `POST /auth/login`
- body: `{ "passphrase": "..." }`
- server checks passphrase against `control` first, then `rx`
- on success: set `HttpOnly` cookie `trx_http_sid`, return `{ role: "rx"|"control" }`
- on failure: 401 generic error
- `POST /auth/logout`
- clears cookie and invalidates server session
- `GET /auth/session`
- returns `{ authenticated: true|false, role?: "rx"|"control" }`
Protected existing endpoints:
- Control APIs (`control` role required): `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, `/toggle_vfo`, `/lock`, `/unlock`, `/set_tx_limit`, `/toggle_*_decode`, `/clear_*_decode`, CW tuning endpoints, etc.
- Read APIs (`rx` or `control`): `/status`, `/events`, `/decode`, `/audio`
TX/PTT hard-gate behavior when `tx_access_control_enabled=true`:
- Do not render TX/PTT controls for unauthenticated or `rx` role.
- Reject TX/PTT and mutating control endpoints unless role is `control`.
- Prefer returning `404` for hidden TX/PTT endpoints to avoid capability leakage
(or `403` if explicit error semantics are preferred).
Public endpoints:
- `/` (HTML shell)
- static assets (`/style.css`, `/app.js`, plugin js, logo, favicon)
- `/auth/*`
## Middleware Behavior
Implement Actix middleware/wrap fn in `trx-frontend-http`:
- Resolve session from cookie.
- Validate in store and expiry.
- If missing/invalid:
- API routes: return `401` JSON/text
- SSE/WS routes: return `401`
- If valid:
- enforce route role (`rx` or `control`)
- return `403` when authenticated but role is insufficient
- continue request
- optionally slide expiry (`last_seen + ttl`) with cap.
Keep middleware route-aware by checking request path against allowlist.
## Passphrase Handling
- Use exact passphrase comparison against config values (no hash layer in this draft).
- Still use constant-time string comparison helper to reduce timing leakage.
- Keep passphrases out of logs and API responses.
## Cookie Settings
Session cookie:
- `HttpOnly=true`
- `Secure` configurable (true for TLS)
- `SameSite=Lax` default
- `Path=/`
- Max-Age = session TTL
## Frontend Flow
In `assets/web/app.js`:
1. On startup call `/auth/session`.
2. If unauthenticated, show blocking screen with logo + `Access denied`.
3. Submit to `/auth/login`.
4. On success initialize normal app flow (`connect()`, decode stream).
5. If role is `rx`, disable/hide all TX/PTT/mutating controls.
6. If role is `control`, enable full UI.
7. If protected call returns 401/403, stop streams and return to login panel.
8. Add logout button in About tab or header.
UI minimal requirement:
- Default unauthenticated view: logo + `Access denied` + passphrase field + login button.
- Generic error message on failure.
- No passphrase persistence in localStorage.
## Implementation Steps
1. Extend client config structs + parser defaults.
2. Build auth state (passphrases + session store) in HTTP server startup.
3. Add `/auth/login`, `/auth/logout`, `/auth/session` handlers.
4. Add middleware and protect selected routes.
5. Update frontend JS with login gate and 401 handling.
6. Add docs to `README.md` + `trx-client.toml.example`.
7. Add role matrix tests and frontend role UI handling.
## Test Plan
Unit tests:
- Config validation combinations.
- Login success/failure.
- Session expiry.
- Middleware path allowlist/protection.
- Role enforcement (`rx` denied on control routes).
- TX visibility policy (`tx_access_control_enabled`) endpoint behavior.
Integration tests (Actix test server):
- Unauthed call to `/set_freq` -> 401.
- `rx` login -> cookie set -> `/status` accepted, `/set_freq` -> 403.
- `control` login -> `/set_freq` accepted.
- With `tx_access_control_enabled=true`, unauth/`rx` cannot use `/set_ptt`.
- Expired session -> 401.
- `/events` and `/audio` reject unauthenticated clients.
Manual checks:
- Browser login works.
- WSJT-X/hamlib unaffected (non-http frontends).
- Auth disabled mode behaves exactly as before.
## Operational Notes
- This is in-memory session state. Restart invalidates sessions.
- For reverse proxy deployments, use TLS and set `cookie_secure=true`.
- If remote exposure is possible, use strong passphrase and firewall.
## Future Extensions
- Optional API bearer token for automation scripts.
- Optional migration to hashed passphrases if threat model increases.
- Persistent sessions with signed tokens/JWT (if needed).
- Optional TOTP second factor for internet-exposed deployments.
+231
View File
@@ -0,0 +1,231 @@
# Configuration
This document lists all currently supported configuration options for `trx-server` and `trx-client`.
## File Locations
### `trx-server`
Configuration lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
### `trx-client`
Configuration lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
CLI options override file values.
## Environment Variables
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by both server and client.
## `trx-server` Options
### `[general]`
- `callsign` (`string`, default: `"N0CALL"`)
- `log_level` (`string`, optional): one of `trace|debug|info|warn|error`
- `latitude` (`float`, optional): `-90..=90`
- `longitude` (`float`, optional): `-180..=180`
Notes:
- `latitude` and `longitude` must be set together or both omitted.
### `[rig]`
- `model` (`string`, required effectively unless provided by CLI `--rig`)
- `initial_freq_hz` (`u64`, default: `144300000`, must be `> 0`)
- `initial_mode` (`string`, default: `"USB"`): one of `LSB|USB|CW|CWR|AM|WFM|FM|DIG|PKT`
### `[rig.access]`
- `type` (`string`, default behavior: `serial` if omitted): `serial|tcp|sdr`
- Serial mode:
- `port` (`string`)
- `baud` (`u32`)
- TCP mode:
- `host` (`string`)
- `tcp_port` (`u16`)
- SDR mode:
- `args` (`string`, required when `type = "sdr"`): SoapySDR device args string (e.g. `"driver=rtlsdr"` or `"driver=airspy,serial=00000001"`). Passed verbatim to `SoapySDR::Device::new()`.
Notes:
- For `serial`, both `port` and `baud` are required.
- For `tcp`, both `host` and `tcp_port` are required.
- For `sdr`, `args` must be non-empty. The `port`, `baud`, `host`, and `tcp_port` fields are ignored.
### `[behavior]`
- `poll_interval_ms` (`u64`, default: `500`, must be `> 0`)
- `poll_interval_tx_ms` (`u64`, default: `100`, must be `> 0`)
- `max_retries` (`u32`, default: `3`, must be `> 0`)
- `retry_base_delay_ms` (`u64`, default: `100`, must be `> 0`)
### `[listen]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `4530`, must be `> 0` when enabled)
### `[listen.auth]`
- `tokens` (`string[]`, default: `[]`)
Notes:
- Empty token strings are invalid.
- Empty list means no auth required.
### `[audio]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `4531`, must be `> 0` when enabled)
- `rx_enabled` (`bool`, default: `true`)
- `tx_enabled` (`bool`, default: `true`)
- `device` (`string`, optional)
- `sample_rate` (`u32`, default: `48000`, valid: `8000..=192000`)
- `channels` (`u8`, default: `1`, valid: `1|2`)
- `frame_duration_ms` (`u16`, default: `20`, valid: `3|5|10|20|40|60`)
- `bitrate_bps` (`u32`, default: `24000`, must be `> 0`)
Notes:
- When `[audio].enabled = true`, at least one of `rx_enabled` or `tx_enabled` must be true.
### `[pskreporter]`
- `enabled` (`bool`, default: `false`)
- `host` (`string`, default: `"report.pskreporter.info"`, must not be empty when enabled)
- `port` (`u16`, default: `4739`, must be `> 0` when enabled)
- `receiver_locator` (`string`, optional)
Notes:
- If `receiver_locator` is omitted, server tries deriving it from `[general].latitude`/`longitude`.
- PSK Reporter software ID is hardcoded to: `trx-server v<version> by SP2SJG`.
### `[aprsfi]`
- `enabled` (`bool`, default: `false`)
- `host` (`string`, default: `"rotate.aprs.net"`, must not be empty when enabled)
- `port` (`u16`, default: `14580`, must be `> 0` when enabled)
- `passcode` (`i32`, default: `-1`)
Notes:
- When `passcode = -1` (the default), the passcode is auto-computed from `[general].callsign` using the standard APRS-IS hash algorithm.
- `[general].callsign` must be non-empty when `[aprsfi].enabled = true`; otherwise the IGate is silently disabled at startup.
- Only APRS packets with valid CRC are forwarded; packets from other decoders (FT8, WSPR, CW) are ignored.
- The IGate reconnects automatically with exponential backoff (1 s → 2 s → … → 60 s) on TCP errors.
- Requires `[audio].enabled = true` (APRS packets are decoded from audio).
### `[sdr]`
- `sample_rate` (`u32`, default: `1920000`, must be `> 0`): IQ capture rate in Hz. Must be supported by the device.
- `bandwidth` (`u32`, default: `1500000`): Hardware IF filter bandwidth in Hz.
- `center_offset_hz` (`i64`, default: `100000`): The SDR tunes this many Hz below the dial frequency to keep the signal off the DC spur. Negative values tune above.
### `[sdr.gain]`
- `mode` (`string`, default: `"auto"`): `"auto"` enables hardware AGC (falls back to `"manual"` with a warning if the device does not support it); `"manual"` uses the fixed `value`.
- `value` (`f64`, default: `30.0`): Gain in dB. Used only when `mode = "manual"`.
### `[sdr.squelch]`
- `enabled` (`bool`, default: `false`): Enables virtual software squelch for demodulated audio except WFM on the primary SDR channel.
- `threshold_db` (`f32`, default: `-65.0`, valid: `-140..=0`): Open threshold in dBFS.
- `hysteresis_db` (`f32`, default: `3.0`, valid: `0..=40`): Close hysteresis in dB.
- `tail_ms` (`u32`, default: `180`, valid: `0..=10000`): Tail hold time after signal drops below threshold.
### `[[sdr.channels]]`
Defines one virtual receiver channel within the wideband IQ stream. At least one channel is required when using the `soapysdr` backend. The **first** channel in the list is the *primary* channel: `set_freq` and `set_mode` from rig control apply to it, and `get_status` reads from it.
- `id` (`string`, default: `""`): Human-readable label used in logs.
- `offset_hz` (`i64`, default: `0`): Frequency offset from the dial frequency in Hz. Primary channel should be `0`.
- `mode` (`string`, default: `"auto"`): Demodulation mode. `"auto"` follows the RigCat `set_mode` command; or a fixed mode string: `LSB`, `USB`, `CW`, `CWR`, `AM`, `WFM`, `FM`, `DIG`, `PKT`.
- `audio_bandwidth_hz` (`u32`, default: `3000`): One-sided bandwidth of the post-demodulation audio BPF in Hz.
- `fir_taps` (`usize`, default: `64`): FIR filter tap count. Higher values give sharper roll-off at the cost of latency.
- `cw_center_hz` (`u32`, default: `700`): CW tone centre frequency in the audio domain (Hz).
- `wfm_bandwidth_hz` (`u32`, default: `75000`): Pre-demodulation filter bandwidth for WFM only (Hz).
- `decoders` (`string[]`, default: `[]`): Decoder IDs that receive this channel's PCM audio. Valid values: `"ft8"`, `"wspr"`, `"aprs"`, `"cw"`. Each decoder ID may appear in at most one channel.
- `stream_opus` (`bool`, default: `false`): Encode this channel's audio as Opus and stream to clients over the TCP audio port. At most one channel may set this to `true`.
Notes:
- Requires `libSoapySDR` installed (`brew install soapysdr` on macOS; `libsoapysdr-dev` on Debian/Ubuntu).
- The SDR backend is RX-only. `[audio] tx_enabled` must be `false`.
- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2` for every channel; violated channels cause a startup error.
- `[audio] sample_rate` must match the output audio rate of the SDR pipeline (48000 Hz recommended).
- Use `trx-server --print-config` to see all defaults. SDR fields appear only if the binary was built with `--features soapysdr`.
### `[decode_logs]`
- `enabled` (`bool`, default: `false`)
- `dir` (`string`, default: `"$XDG_DATA_HOME/trx-rs/decoders"`; fallback: `"logs/decoders"`, must not be empty when enabled)
- `aprs_file` (`string`, default: `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
- `cw_file` (`string`, default: `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
- `ft8_file` (`string`, default: `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
- `wspr_file` (`string`, default: `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"`, must not be empty when enabled)
Notes:
- Decoder logs are server-side and split by decoder name (APRS/CW/FT8/WSPR).
- Files are appended in JSON Lines format (one JSON object per line).
- Supported filename date tokens: `%YYYY%`, `%MM%`, `%DD%` (UTC date).
## `trx-client` Options
### `[general]`
- `callsign` (`string`, default: `"N0CALL"`)
- `log_level` (`string`, optional): one of `trace|debug|info|warn|error`
### `[remote]`
- `url` (`string`, optional in file but required at runtime unless provided by CLI `--url`)
- `poll_interval_ms` (`u64`, default: `750`, must be `> 0`)
### `[remote.auth]`
- `token` (`string`, optional)
Notes:
- If provided, token must not be empty/whitespace.
### `[frontends.http]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `8080`, must be `> 0` when enabled)
### `[frontends.rigctl]`
- `enabled` (`bool`, default: `false`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `4532`, must be `> 0` when enabled)
### `[frontends.http_json]`
- `enabled` (`bool`, default: `true`)
- `listen` (`ip`, default: `127.0.0.1`)
- `port` (`u16`, default: `0`)
- `auth.tokens` (`string[]`, default: `[]`)
Notes:
- `port = 0` means ephemeral bind (allowed).
- Empty token strings are invalid.
### `[frontends.audio]`
- `enabled` (`bool`, default: `true`)
- `server_port` (`u16`, default: `4531`, must be `> 0` when enabled)
- `bridge.enabled` (`bool`, default: `false`): enables local `cpal` audio bridge
- `bridge.rx_output_device` (`string`, optional): exact local playback device name
- `bridge.tx_input_device` (`string`, optional): exact local capture device name
- `bridge.rx_gain` (`float`, default: `1.0`, must be finite and `>= 0`)
- `bridge.tx_gain` (`float`, default: `1.0`, must be finite and `>= 0`)
Notes:
- The bridge is intended for local WSJT-X integration via virtual audio devices.
- Linux: typically use ALSA loopback (`snd-aloop`).
- macOS: install a virtual CoreAudio device (e.g. BlackHole), then set device names above.
## CLI Override Summary
### `trx-server`
- `--config`, `--print-config`
- `--rig`, `--access`, positional `RIG_ADDR`
- `--callsign`
- `--listen`, `--port` (JSON listener)
- SDR backend: all SDR options are file-only (`[sdr]` and `[[sdr.channels]]`).
### `trx-client`
- `--config`, `--print-config`
- `--url`, `--token`, `--poll-interval`
- `--frontend` (comma-separated)
- `--http-listen`, `--http-port`
- `--rigctl-listen`, `--rigctl-port`
- `--http-json-listen`, `--http-json-port`
- `--callsign`
+69
View File
@@ -0,0 +1,69 @@
# Top 5 Real Architecture Issues (Post-Refactor)
## 1) Plugin ABI is still brittle and unversioned
### Files
- `src/trx-app/src/plugins.rs`
- `examples/trx-plugin-example/src/lib.rs`
### Why this matters
Plugin loading is now explicit (good), but still assumes exact symbol names and raw FFI contracts with no ABI/version handshake. A plugin built against an older/newer ABI can fail at runtime in hard-to-diagnose ways.
### Fix steps
1. Add an ABI version symbol/handshake (`trx_plugin_abi_version`) and reject incompatible plugins with clear errors.
2. Split plugin capability metadata (backend/frontend/both) from registration symbols to avoid noisy failed-load logs.
3. Provide a tiny shared plugin-API crate for stable entrypoint signatures.
## 2) Runtime supervision is still ad-hoc (sleep + abort)
### Files
- `src/trx-server/src/main.rs`
- `src/trx-client/src/main.rs`
### Why this matters
Shutdown is coordinated, but supervision still uses a fixed delay plus manual `abort()` over `Vec<JoinHandle<_>>`. This can mask task failures, race shutdown ordering, and make lifecycle behavior harder to reason about.
### Fix steps
1. Move to `JoinSet` (or a small supervisor type) for task ownership and result handling.
2. Replace fixed sleep with bounded graceful-join timeout logic.
3. Surface task failure reasons consistently in one place.
## 3) JSON/TCP transport logic is duplicated across modules
### Files
- `src/trx-server/src/listener.rs`
- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs`
- `src/trx-client/src/remote_client.rs`
### Why this matters
`read_limited_line`, timeout handling, and response write patterns are repeated in multiple places. This increases drift risk and makes protocol hardening changes expensive.
### Fix steps
1. Extract shared JSON-over-TCP helpers into `trx-protocol` (or a small transport crate/module).
2. Keep one source of truth for max line size, timeout behavior, and framing errors.
3. Cover shared transport with focused tests once instead of per-module copies.
## 4) Boundary tests are present but mostly ignored in constrained envs
### Files
- `src/trx-server/src/listener.rs`
- `src/trx-client/src/remote_client.rs`
- `src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs`
### Why this matters
Important network-path tests exist, but are marked `#[ignore]` in this environment due bind restrictions. Without a clear CI strategy, regressions can still slip through.
### Fix steps
1. Add CI jobs/environment where bind-based tests run by default.
2. Split pure transport logic from socket bind/accept so more behavior can be tested without real sockets.
3. Keep ignored tests minimal and document how/when they run.
## 5) Decode/history shared state still relies on global mutexes
### Files
- `src/trx-server/src/audio.rs`
- `src/trx-client/trx-frontend/src/lib.rs`
- `src/trx-client/trx-frontend/trx-frontend-http/src/audio.rs`
### Why this matters
History/state paths still use shared mutex-backed globals/contexts with `expect` on lock poisoning in hot paths. This is workable but fragile for long-running async services.
### Fix steps
1. Replace panic-on-poison lock usage with resilient handling.
2. Consider bounded channel or lock-free append/read model for decode history.
3. Define explicit ownership/lifetime for history data instead of implicit shared mutation.
+156
View File
@@ -0,0 +1,156 @@
# Multi-Rig Support
This document specifies the requirements for running N simultaneous rig backends in one `trx-server` process and the protocol/config changes required to support them.
---
## Progress
> **For AI agents:** This section is the single source of truth for implementation status.
> Each task has a unique ID (e.g. `MR-01`), a status badge, a description, the files it touches, and any blocking dependencies.
>
> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked
### Foundational (parallel)
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| MR-01 | `[x]` | Add `rig_id: Option<String>` to `ClientEnvelope`; add `rig_id: Option<String>` to `ClientResponse`; add `ClientCommand::GetRigs`; add `GetRigsResponseBody` + `RigEntry`; add sentinel arm in `mapping.rs` | `src/trx-protocol/src/types.rs`, `mapping.rs`, `lib.rs` | — |
| MR-02 | `[x]` | Add `RigInstanceConfig`; add `rigs: Vec<RigInstanceConfig>` to `ServerConfig`; implement `resolved_rigs()`; extend `validate()` for unique IDs + unique audio ports | `src/trx-server/src/config.rs` | — |
| MR-03 | `[x]` | Remove four `OnceLock` statics from `audio.rs`; add `DecoderHistories { aprs, ft8, wspr }` struct + `new()`; convert history free-fns to take `&DecoderHistories`; update decoder task signatures + `run_audio_listener` | `src/trx-server/src/audio.rs` | — |
| MR-04 | `[x]` | Create `src/trx-server/src/rig_handle.rs` with `RigHandle { rig_id, rig_tx, state_rx }`; declare mod in `main.rs` | `src/trx-server/src/rig_handle.rs`, `main.rs` | — |
### Sequential
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| MR-05 | `[x]` | Add `rig_id: String` + `histories: Arc<DecoderHistories>` to `RigTaskConfig`; fix `clear_*_history` calls in `process_command` | `src/trx-server/src/rig_task.rs` | MR-03 |
| MR-06 | `[x]` | Rewrite `run_listener` to take `Arc<HashMap<String, RigHandle>>` + `default_rig_id`; route by `envelope.rig_id`; add `GetRigs` fast path; populate `rig_id` in every `ClientResponse` | `src/trx-server/src/listener.rs` | MR-01, MR-04 |
| MR-07 | `[x]` | Rewrite `main.rs` spawn loop over `resolved_rigs()`; extract `spawn_rig_audio_stack()`; per-rig pskreporter + aprsfi; build `HashMap<String, RigHandle>`; pass to `run_listener` | `src/trx-server/src/main.rs` | MR-0206 |
### Tests
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| MR-08 | `[x]` | Config tests: `resolved_rigs()` with multi-rig TOML and legacy TOML; duplicate ID/port rejection | `src/trx-server/src/config.rs` | MR-02 |
| MR-09 | `[x]` | Protocol tests: `ClientEnvelope` absent `rig_id` parses; `rig_id` in responses; `GetRigs` round-trip; existing tests still pass | `src/trx-protocol/src/codec.rs` | MR-01 |
---
## Goals
- Run N simultaneous rig backends (SDR, transceivers, or any mix) in one server process
- Route control commands to the correct rig via `rig_id` in the JSON protocol
- Backward compatibility: single-rig configs (`[rig]`/`[audio]` at top level) continue to work unchanged
- Per-rig audio streaming on separate TCP ports
- New `GetRigs` command to enumerate all connected rigs and their states
---
## Non-Goals
- Load-balancing or failover between rigs
- Sharing a single audio port across multiple rigs (each rig keeps its own port)
---
## Architecture
```
Single [listen] port (4530)
└─ listener.rs: Arc<HashMap<rig_id, RigHandle>>
├─ route by envelope.rig_id (absent → first rig, backward compat)
└─ GetRigs → aggregate all states
Per-rig:
rig_task ←→ RigHandle (rig_tx + state_rx)
audio capture → pcm_tx → decoder tasks → decode_tx
run_audio_listener (own TCP port per rig)
pskreporter + aprsfi tasks
```
---
## TOML Format
### Multi-rig (`[[rigs]]` array)
```toml
[general]
callsign = "W1AW"
[listen]
port = 4530
[[rigs]]
id = "hf"
[rigs.rig]
model = "ft450d"
initial_freq_hz = 14074000
[rigs.rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[rigs.audio]
port = 4531
[[rigs]]
id = "sdr"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
[rigs.audio]
port = 4532
[rigs.sdr]
sample_rate = 1920000
```
### Legacy (flat `[rig]` + `[audio]`) — continues to work unchanged
```toml
[rig]
model = "ft817"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[audio]
port = 4531
```
Legacy configs are synthesised into a single-element `[[rigs]]` list with `id = "default"` via `resolved_rigs()`.
---
## Protocol Wire Format
Request (`rig_id` optional; absent = first rig):
```json
{"rig_id": "hf", "cmd": "set_freq", "freq_hz": 14074000}
{"cmd": "get_state"}
```
Response (`rig_id` always present):
```json
{"success": true, "rig_id": "hf", "state": {...}}
{"success": false, "rig_id": "default", "error": "Unknown rig_id: xyz"}
```
`GetRigs` response:
```json
{"success": true, "rig_id": "server", "rigs": [
{"rig_id": "hf", "state": {...}},
{"rig_id": "sdr", "state": {...}}
]}
```
---
## Validation Rules (startup)
- When `[[rigs]]` is non-empty: each `id` must be unique (case-sensitive).
- When `[[rigs]]` is non-empty: each `audio.port` must be unique.
- When `[[rigs]]` is empty: legacy flat fields are used with `id = "default"`.
- Mixing `[[rigs]]` and legacy flat `[rig]`/`[audio]` is undefined; `[[rigs]]` takes precedence.
+326
View File
@@ -0,0 +1,326 @@
# trx-rs Project Overview
## What is trx-rs?
**trx-rs** is a modular transceiver (radio) control stack written in Rust. It provides a backend service for controlling amateur radio transceivers via CAT (Computer-Aided Transceiver) protocols, with multiple frontend interfaces for access and monitoring.
### Current Capabilities
| Feature | Status |
|---------|--------|
| Yaesu FT-817 CAT control | Implemented |
| HTTP/Web UI with SSE | Implemented |
| rigctl-compatible TCP | Implemented |
| VFO A/B switching | Implemented |
| PTT control | Implemented |
| Signal/TX power metering | Implemented |
| Front panel lock | Implemented |
| Multiple rig backends | Extensible (only FT-817) |
| Backend/frontend registry | Implemented |
| TCP CAT transport | Partial (config wiring only) |
| JSON TCP control (line-delimited) | Implemented (configurable frontend) |
| Plugin registry loading | Implemented (shared libraries) |
| Configuration file (TOML) | Implemented |
| Rig state machine | Implemented |
| Command handlers | Implemented |
| Event notifications | Implemented (rig task emits events) |
| Retry/polling policies | Implemented |
| Controller-based rig task | Implemented |
---
## Current Architecture
```
┌──────────────────────────────────────────────────────────────────────────┐
│ trx-server/trx-client │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ Application │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Config │ │ CLI │ │ Rig Task │ │ │
│ │ │ (TOML file) │ │ (clap) │ │ (main loop) │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ trx-core │ │ Frontend Layer │ │
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ controller/ │ │ │ │ HTTP │ │ │
│ │ │ - machine │ │ │ │ (REST+SSE) │ │ │
│ │ │ - handlers │ │ │ └───────────────┘ │ │
│ │ │ - events │ │ │ ┌───────────────┐ │ │
│ │ │ - policies │ │ │ │ HTTP JSON │ │ │
│ │ └───────────────┘ │ │ │ (TCP/JSON) │ │ │
│ └─────────────────────┘ │ └───────────────┘ │ │
│ │ │ ┌───────────────┐ │ │
│ │ │ │ rigctl │ │ │
│ │ │ │ (TCP/hamlib) │ │ │
│ │ │ └───────────────┘ │ │
│ │ └─────────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ trx-backend │ │
│ │ ┌───────────────┐ │ │
│ │ │ FT-817 Driver │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
```
### Key Components
| Component | Purpose |
|-----------|---------|
| `trx-core` | Core types, traits (`Rig`, `RigCat`), state definitions, controller components |
| `trx-core/rig/controller` | State machine, command handlers, event system, policies |
| `trx-backend` | Backend factory and abstraction layer |
| `trx-backend-ft817` | FT-817 CAT protocol implementation |
| `trx-frontend` | Frontend trait (`FrontendSpawner`) |
| `trx-frontend-http` | Web UI with REST API and SSE |
| `trx-frontend-http-json` | JSON-over-TCP control frontend |
| `trx-frontend-rigctl` | Hamlib rigctl-compatible TCP interface |
| `trx-server` | Server binary — connects to rig backend, exposes JSON TCP control |
| `trx-client` | Client binary — connects to server, runs frontends (HTTP, rigctl) |
---
## Configuration
trx-rs supports TOML configuration files with the following search order:
1. `--config <path>` (explicit CLI argument)
2. `./trx-server.toml` or `./trx-client.toml` (current directory)
3. `~/.config/trx-rs/config.toml` (XDG user config)
4. `/etc/trx-rs/config.toml` (system-wide)
CLI arguments override config file values.
Plugin discovery:
- Uses shared libraries with a `trx_register` entrypoint.
- Searches `./plugins`, `~/.config/trx-rs/plugins`, and any paths in `TRX_PLUGIN_DIRS`.
### Example Configuration
```toml
[general]
callsign = "N0CALL"
[rig]
model = "ft817"
initial_freq_hz = 144300000
initial_mode = "USB"
[rig.access]
type = "serial"
port = "/dev/ttyUSB0"
baud = 9600
[frontends.http]
enabled = true
listen = "127.0.0.1"
port = 8080
[frontends.rigctl]
enabled = true
listen = "127.0.0.1"
port = 4532
[frontends.http_json]
enabled = true
listen = "127.0.0.1"
port = 9000
auth.tokens = ["demo-token"]
[behavior]
poll_interval_ms = 500
poll_interval_tx_ms = 100
max_retries = 3
retry_base_delay_ms = 100
```
Use `trx-server --print-config` or `trx-client --print-config` to generate an example configuration.
---
## Rig Controller Components
Located in `trx-core/src/rig/controller/`:
### State Machine (`machine.rs`)
Explicit state machine for rig lifecycle management:
```rust
pub enum RigMachineState {
Disconnected,
Connecting { started_at: Option<u64> },
Initializing { rig_info: Option<RigInfo> },
PoweredOff { rig_info: RigInfo },
Ready(ReadyStateData),
Transmitting(TransmittingStateData),
Error { error: RigStateError, previous_state: Box<RigMachineState> },
}
```
Events trigger state transitions:
- `RigEvent::Connected`, `Initialized`, `PoweredOn`, `PoweredOff`
- `RigEvent::PttOn`, `PttOff`
- `RigEvent::Error(RigStateError)`, `Recovered`, `Disconnected`
### Command Handlers (`handlers.rs`)
Trait-based command system with validation:
```rust
pub trait RigCommandHandler: Debug + Send + Sync {
fn name(&self) -> &'static str;
fn can_execute(&self, ctx: &dyn CommandContext) -> ValidationResult;
fn execute<'a>(&'a self, executor: &'a mut dyn CommandExecutor)
-> Pin<Box<dyn Future<Output = DynResult<CommandResult>> + Send + 'a>>;
}
```
Implemented commands:
- `SetFreqCommand`, `SetModeCommand`, `SetPttCommand`
- `PowerOnCommand`, `PowerOffCommand`
- `ToggleVfoCommand`, `LockCommand`, `UnlockCommand`
- `GetTxLimitCommand`, `SetTxLimitCommand`, `GetSnapshotCommand`
The rig task (`trx-server/src/rig_task.rs`) now syncs the state machine to the live `RigState`
and emits events whenever rig status changes.
### Event Notifications (`events.rs`)
Typed event system for rig state changes:
```rust
pub trait RigListener: Send + Sync {
fn on_frequency_change(&self, old: Option<Freq>, new: Freq);
fn on_mode_change(&self, old: Option<&RigMode>, new: &RigMode);
fn on_ptt_change(&self, transmitting: bool);
fn on_state_change(&self, old: &RigMachineState, new: &RigMachineState);
fn on_meter_update(&self, rx: Option<&RigRxStatus>, tx: Option<&RigTxStatus>);
fn on_lock_change(&self, locked: bool);
fn on_power_change(&self, powered: bool);
}
pub struct RigEventEmitter {
// Manages listeners and dispatches events
}
```
### Policies (`policies.rs`)
Configurable retry and polling behavior:
```rust
pub trait RetryPolicy: Send + Sync {
fn should_retry(&self, attempt: u32, error: &RigError) -> bool;
fn delay(&self, attempt: u32) -> Duration;
fn max_attempts(&self) -> u32;
}
pub trait PollingPolicy: Send + Sync {
fn interval(&self, transmitting: bool) -> Duration;
fn should_poll(&self, transmitting: bool) -> bool;
}
```
Implementations:
- `ExponentialBackoff` - Exponential delay with max cap
- `FixedDelay` - Constant delay between retries
- `NoRetry` - Fail immediately
- `AdaptivePolling` - Faster polling during TX
- `FixedPolling` - Constant interval
- `NoPolling` - Disable automatic polling
### Error Types
`RigError` now includes error classification:
```rust
pub struct RigError {
pub message: String,
pub kind: RigErrorKind, // Transient or Permanent
}
impl RigError {
pub fn timeout() -> Self; // Transient
pub fn communication(msg) -> Self; // Transient
pub fn invalid_state(msg) -> Self; // Permanent
pub fn not_supported(op) -> Self; // Permanent
pub fn is_transient(&self) -> bool;
}
```
---
## Remaining Improvement Opportunities
### Integration Work
1. **Plugin UX improvements** - Add structured plugin metadata (name, version, capabilities) and surface it in CLI help.
### Testing
- Add integration tests with mock backends
- Add more backend/frontend unit tests
### Features
- Add more rig backends (IC-7300, TS-590, etc.)
- Add TX limit support for FT-817 (or document per-backend constraints in UI)
- Add WebSocket support for bidirectional communication
- Add metrics/telemetry export (Prometheus)
- Add authentication for HTTP frontend
### Code Quality
- Add CI/CD pipeline
- Add pre-commit hooks
---
## Implementation Status
| Component | Status | Tests |
|-----------|--------|-------|
| State Machine | Implemented | 5 tests |
| Command Handlers | Implemented | 3 tests |
| Event Notifications | Implemented | 2 tests |
| Retry/Polling Policies | Implemented | 5 tests |
| Config File Support | Implemented | 4 tests |
| rigctl Frontend | Implemented | - |
| HTTP Frontend | Implemented | - |
| FT-817 Backend | Implemented | - |
**Total: 19 unit tests passing**
---
## Building and Running
```bash
# Build
cargo build --release
# Run server with CLI args
./target/release/trx-server -r ft817 "/dev/ttyUSB0 9600"
# Run server with config file
./target/release/trx-server --config /path/to/config.toml
# Run client
./target/release/trx-client --config /path/to/client-config.toml
# Print example config
./target/release/trx-server --print-config > trx-server.toml
# Run tests
cargo test
# Run clippy
cargo clippy
```
+401
View File
@@ -0,0 +1,401 @@
# SDR Backend Requirements
This document specifies the requirements for a SoapySDR-based RX-only backend (`trx-backend-soapysdr`) and the associated IQ-to-audio pipeline changes in `trx-server`.
---
## Progress
> **For AI agents:** This section is the single source of truth for implementation status.
> Each task has a unique ID (e.g. `SDR-01`), a status badge, a description, the files it touches, and any blocking dependencies.
> Pick any task whose status is `[ ]` and whose `Needs` list is fully `[x]`. Update status to `[~]` while working, `[x]` when merged. Record notes under the task if you hit non-obvious issues.
>
> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked
### Foundational (must land first)
| ID | Status | Task | Touches |
|----|--------|------|---------|
| SDR-01 | `[x]` | Add `AudioSource` trait to `trx-core`; add `as_audio_source()` default on `RigCat` | `src/trx-core/src/rig/mod.rs` |
| SDR-02 | `[x]` | Add `RigAccess::Sdr { args: String }` variant; register `soapysdr` factory (feature-gated `soapysdr`) | `src/trx-server/trx-backend/src/lib.rs` |
| SDR-03 | `[x]` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig` structs; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig`; add startup validation rules (§11) | `src/trx-server/src/config.rs` |
### New crate: `trx-backend-soapysdr`
| ID | Status | Task | Touches | Needs |
|----|--------|------|---------|-------|
| SDR-04 | `[x]` | Create crate scaffold: `Cargo.toml` (deps: `soapysdr`, `num-complex`, `tokio`), empty `lib.rs` | `src/trx-server/trx-backend/trx-backend-soapysdr/` | SDR-01, SDR-02 |
| SDR-05 | `[x]` | Implement `demod.rs`: SSB (USB/LSB), AM envelope, FM quadrature, CW narrow BPF+envelope | `…/src/demod.rs` | SDR-04 |
| SDR-06 | `[x]` | Implement `dsp.rs`: IQ broadcast loop (SoapySDR read thread → `broadcast::Sender<Vec<Complex<f32>>>`); per-channel mixer → FIR LPF → decimator → demod → frame accumulator → `broadcast::Sender<Vec<f32>>` | `…/src/dsp.rs` | SDR-04, SDR-05 |
| SDR-07 | `[x]` | Implement `SoapySdrRig` in `lib.rs`: `RigCat` (RX methods + `not_supported` stubs for TX), `AudioSource`, gain control (manual/auto with fallback), primary channel freq/mode tracking | `…/src/lib.rs` | SDR-03, SDR-06 |
### Server integration
| ID | Status | Task | Touches | Needs |
|----|--------|------|---------|-------|
| SDR-08 | `[x]` | `main.rs`: after building rig, if `as_audio_source()` is `Some` skip cpal, subscribe each decoder and the Opus encoder to the appropriate channel PCM senders; validate `stream_opus` count ≤ 1 | `src/trx-server/src/main.rs` | SDR-03, SDR-07 |
| SDR-09 | `[x]` | Add `trx-backend-soapysdr` to workspace `Cargo.toml`; update `CONFIGURATION.md` with new `[sdr]` / `[[sdr.channels]]` options | `Cargo.toml`, `CONFIGURATION.md` | SDR-04 |
### Validation & tests
| ID | Status | Task | Touches | Needs |
|----|--------|------|---------|-------|
| SDR-10 | `[x]` | Unit tests for `demod.rs`: known-input tone through each demodulator, check output frequency correct | `…/src/demod.rs` | SDR-05 |
| SDR-11 | `[x]` | Unit tests for config validation: channel IF out-of-range, dual `stream_opus`, TX enabled with SDR backend, AGC fallback warning | `src/trx-server/src/config.rs` | SDR-03 |
---
## Goals
- Receive-only backend that uses any SoapySDR-compatible device (RTL-SDR, Airspy, HackRF, SDRplay, etc.) as the rig
- Full IQ pipeline: raw IQ samples → demodulated PCM → existing decoders (FT8, WSPR, APRS, CW) with zero decoder-side changes
- Wideband capture: one SDR IQ stream feeds multiple simultaneous virtual receivers, each independently tuned and demodulated
- Configurable per-channel filters and demodulation modes
- Demodulated audio streamed to clients as Opus over the existing TCP audio channel
---
## Non-Goals
- Transmit (TX/PTT) of any kind
- Replacing or deprecating the existing cpal-based audio path (it stays for transceiver backends)
---
## 1. Device Abstraction
### 1.1 `RigAccess` extension
A new access type `sdr` is added alongside `serial` and `tcp`:
```toml
[rig.access]
type = "sdr"
args = "driver=rtlsdr" # SoapySDR device args string
```
The `args` value is passed verbatim to `SoapySDR::Device::new(args)`. It follows SoapySDR's key=value comma-separated convention (e.g., `driver=airspy`, `driver=rtlsdr,serial=00000001`).
### 1.2 `AudioSource` trait
A new trait is added to `trx-core` (`src/trx-core/src/rig/mod.rs`):
```rust
pub trait AudioSource: Send + Sync {
/// Subscribe to demodulated PCM audio from the primary channel.
fn subscribe_pcm(&self) -> broadcast::Receiver<Vec<f32>>;
}
```
`RigCat` gains a default opt-in method:
```rust
pub trait RigCat: Rig + Send {
// ... existing methods ...
fn as_audio_source(&self) -> Option<&dyn AudioSource> { None }
}
```
`SoapySdrRig` overrides `as_audio_source()` to return `Some(self)`. When the server detects this, it skips spawning the cpal capture thread entirely.
### 1.3 TX-only `RigCat` methods
The following methods return `RigError::not_supported(...)` on the SDR backend:
- `set_ptt()`
- `power_on()` / `power_off()`
- `get_tx_power()`
- `get_tx_limit()` / `set_tx_limit()`
- `toggle_vfo()` (not applicable; channels are defined statically in config)
- `lock()` / `unlock()`
The following methods are fully supported:
- `get_status()` → returns primary channel's current `(freq, mode, None)`
- `set_freq()` → re-tunes the SDR center frequency (keeping `center_offset_hz` invariant) and updates all channel mixer offsets
- `set_mode()` → changes the primary channel's demodulator
- `get_signal_strength()` → returns instantaneous RSSI for the primary channel (dBFS mapped to 0255 S-unit range)
---
## 2. IQ Pipeline Architecture
### 2.1 Center frequency offset
SDR hardware has a DC offset spur at exactly 0 Hz in the IQ spectrum. To keep the primary channel off DC, the SDR is tuned to a frequency offset from the desired dial frequency:
```
sdr_center_freq = dial_freq - center_offset_hz
```
With `center_offset_hz = 200000` and dial freq 14.074 MHz, the SDR tunes to 13.874 MHz. The 14.074 MHz signal appears at +200 kHz in the IQ spectrum and is mixed down to baseband in software.
`center_offset_hz` is a global SDR parameter (not per-channel). A reasonable default is `100000` (100 kHz).
### 2.2 Wideband channel model
One SoapySDR RX stream produces IQ samples at `sdr.sample_rate` (e.g. 1.92 MHz). This stream is shared among all configured channels. Each channel defines an independent virtual receiver:
```
SoapySDR RX stream (complex f32, sdr_sample_rate Hz)
├──► Channel 0 (primary) offset_hz=0, mode=USB, bw=3000 Hz
├──► Channel 1 (wspr) offset_hz=+21600, mode=USB, bw=3000 Hz
└──► Channel N ...
```
A **channel's frequency** in the real spectrum is:
```
channel_real_freq = dial_freq + channel.offset_hz
```
A **channel's IF frequency** within the IQ stream is:
```
channel_if_hz = center_offset_hz + channel.offset_hz
```
This is the frequency at which the channel's signal appears in the captured IQ bandwidth, and is what the channel's mixer shifts to baseband.
**Constraint:** `|channel_if_hz|` must be less than `sdr_sample_rate / 2` for every channel. The server validates this at startup and rejects invalid configs.
### 2.3 Per-channel DSP chain
Each channel runs the following stages independently on the shared IQ stream:
```
IQ input (complex f32, sdr_sample_rate)
1. Mixer: multiply by exp(-j·2π·channel_if_hz·n/sdr_sample_rate)
→ complex f32 centred at 0 Hz
2. FIR LPF: cutoff = audio_bandwidth_hz / 2, order configurable
3. Decimator: sdr_sample_rate / audio_sample_rate (must be integer; resampler used otherwise)
4. Demodulator (mode-dependent, see §3)
5. Output: real f32 at audio_sample_rate
6. Frame accumulator: chunks of frame_duration_ms
7. broadcast::Sender<Vec<f32>> → decoders + optional Opus encoder
```
Channels run concurrently in separate tasks, all reading from the same raw IQ broadcast channel.
### 2.4 IQ broadcast channel
The SoapySDR read loop runs in a dedicated OS thread (matching the existing cpal thread model). It reads IQ sample blocks from the device and publishes them on:
```rust
broadcast::Sender<Vec<Complex<f32>>> // capacity: configurable, default 64 blocks
```
Each channel task subscribes to this sender. Lagged receivers log a warning and continue.
---
## 3. Demodulators
Demodulator is selected per-channel based on `mode`. Modes map as follows:
| `RigMode` | Demodulator |
|-----------|-------------|
| `USB` | SSB: mix to IF, take real part (upper sideband) |
| `LSB` | SSB: mix to IF, take real part (lower sideband, negate IF) |
| `AM` | Envelope detector: `sqrt(I² + Q²)`, DC-remove, normalize |
| `FM` | Quadrature: `arg(s[n] · conj(s[n-1]))`, i.e. instantaneous frequency |
| `WFM` | Same as FM, wider pre-demod filter (`wfm_bandwidth_hz`) |
| `CW` | Narrow BPF centred at `cw_center_hz` (audio domain), then envelope |
| `DIG`/`PKT` | Same as USB (pass audio through for downstream digital decoders) |
| `CWR` | Same as CW (reversed sideband, uses same audio envelope) |
For SSB modes (USB/LSB), after mixing to baseband the channel's `audio_bandwidth_hz` defines the one-sided cutoff of the post-demod LPF.
---
## 4. Gain Control
Gain is configured globally under `[sdr.gain]`.
```toml
[sdr.gain]
mode = "auto" # "auto" (AGC via SoapySDR) or "manual"
value = 30.0 # dB; ignored when mode = "auto"
```
- **`auto`**: calls `device.set_gain_mode(SOAPY_SDR_RX, 0, true)` to enable hardware AGC if the device supports it. If the device does not support hardware AGC, falls back to `manual` with a warning.
- **`manual`**: calls `device.set_gain(SOAPY_SDR_RX, 0, value)` with the specified total gain in dB.
Advanced per-element gain is out of scope for this phase (no `lna`/`vga`/`if` sub-keys initially).
### 4.1 Virtual Squelch
Software squelch is configured globally under `[sdr.squelch]` and currently applies to the primary channel's demodulated audio path except WFM.
```toml
[sdr.squelch]
enabled = false
threshold_db = -65.0 # dBFS open threshold
hysteresis_db = 3.0 # dB close hysteresis
tail_ms = 180 # hold time after signal drops
```
---
## 5. Filter Configuration
Filters are configured per-channel. The following are settable:
```toml
[[sdr.channels]]
audio_bandwidth_hz = 3000 # One-sided bandwidth of post-demod BPF (Hz)
# For FM: deviation hint for deemphasis
fir_taps = 64 # FIR filter tap count (default 64); higher = sharper roll-off
cw_center_hz = 700 # CW tone centre in audio domain (default 700 Hz)
wfm_bandwidth_hz = 75000 # Pre-demod bandwidth for WFM only (default 75 kHz)
```
`fir_taps` controls the same FIR used in stage 2 of the DSP chain (§2.3). It applies uniformly to both the pre-demod decimation filter and the post-demod audio BPF in this phase.
---
## 6. Channel Configuration and Decoder Binding
Channels are declared as a TOML array. The first channel in the list is the **primary channel** and is the one exposed via `RigCat` (`set_freq`/`set_mode` affect it; `get_status` reads from it).
```toml
[[sdr.channels]]
id = "primary" # Identifier, used in logs
offset_hz = 0 # Offset from dial frequency (Hz)
mode = "auto" # "auto" = follows RigCat set_mode; or fixed RigMode string
audio_bandwidth_hz = 3000
fir_taps = 64
decoders = ["ft8", "cw"] # Which decoders receive this channel's PCM
stream_opus = true # Encode and stream via TCP audio channel
[[sdr.channels]]
id = "wspr-14"
offset_hz = 21600 # 14.0956 MHz when dial = 14.074 MHz
mode = "USB" # Fixed mode, ignores RigCat set_mode
audio_bandwidth_hz = 3000
decoders = ["wspr"]
stream_opus = false
[[sdr.channels]]
id = "aprs"
offset_hz = -673600 # e.g. 144.390 MHz when dial = 145.0635 MHz
mode = "FM"
audio_bandwidth_hz = 8000
decoders = ["aprs"]
stream_opus = false
```
**`mode = "auto"`** means the channel's demodulator tracks whatever `set_mode()` last set on the backend. Only the primary channel should use `"auto"` in typical use.
**`decoders`** maps to the decoder task IDs: `"ft8"`, `"wspr"`, `"aprs"`, `"cw"`. Each named decoder subscribes to the PCM broadcast channel of the listed channel(s). A decoder can only be bound to one channel (first binding wins if duplicated).
---
## 7. Opus Streaming
Channels with `stream_opus = true` have their demodulated PCM Opus-encoded and streamed over the server's existing TCP audio port (default 4531).
For this phase, only **one channel** may have `stream_opus = true` (validation error otherwise). This channel's Opus stream replaces what cpal would have produced — the TCP audio protocol and client-side handling are unchanged.
The Opus encoder uses the `[audio]` config for `frame_duration_ms`, `bitrate_bps`, and `sample_rate`. The SDR pipeline must output PCM at the same `sample_rate` as `[audio]`; a mismatch is a startup validation error.
---
## 8. Full Configuration Example
```toml
[rig]
model = "soapysdr"
initial_freq_hz = 14074000
initial_mode = "USB"
[rig.access]
type = "sdr"
args = "driver=rtlsdr"
[sdr]
sample_rate = 1920000 # IQ capture rate (Hz) — must be supported by device
bandwidth = 1500000 # Hardware IF filter (Hz)
center_offset_hz = 200000 # SDR tunes this many Hz below dial frequency
[sdr.gain]
mode = "auto"
value = 30.0 # Effective only when mode = "manual"
[sdr.squelch]
enabled = false
threshold_db = -65.0
hysteresis_db = 3.0
tail_ms = 180
[[sdr.channels]]
id = "primary"
offset_hz = 0
mode = "auto"
audio_bandwidth_hz = 3000
fir_taps = 64
decoders = ["ft8", "cw"]
stream_opus = true
[[sdr.channels]]
id = "wspr"
offset_hz = 21600
mode = "USB"
audio_bandwidth_hz = 3000
decoders = ["wspr"]
stream_opus = false
[audio]
enabled = true
listen = "127.0.0.1"
port = 4531
rx_enabled = true
tx_enabled = false # No TX on SDR backend
sample_rate = 48000
channels = 1
frame_duration_ms = 20
bitrate_bps = 24000
```
---
## 9. Code Changes Map
| File | Change |
|------|--------|
| `Cargo.toml` (workspace) | Add `src/trx-server/trx-backend/trx-backend-soapysdr` member |
| `src/trx-core/src/rig/mod.rs` | Add `AudioSource` trait; add `as_audio_source()` default to `RigCat` |
| `src/trx-server/trx-backend/src/lib.rs` | Add `RigAccess::Sdr { args }` variant; register `soapysdr` factory (feature-gated) |
| `src/trx-server/src/config.rs` | Add `SdrConfig`, `SdrGainConfig`, `SdrChannelConfig`; parse `type = "sdr"` in `AccessConfig`; add `sdr: SdrConfig` to `ServerConfig` |
| `src/trx-server/src/main.rs` | After building rig: if `as_audio_source()` is `Some`, skip cpal, use `AudioSource::subscribe_pcm()` for each decoder and for the Opus encoder; validate at most one `stream_opus = true` channel |
| `src/trx-server/src/audio.rs` | Expose `spawn_audio_capture` and `run_*_decoder` without assuming cpal as the sole source; no functional change needed — decoders already take `broadcast::Receiver<Vec<f32>>` |
| `src/trx-server/trx-backend/trx-backend-soapysdr/Cargo.toml` | New crate |
| `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | `SoapySdrRig`: implements `RigCat` + `AudioSource`; spawns IQ read thread and channel DSP tasks |
| `src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs` | IQ broadcast loop; per-channel mixer, FIR, decimator, demodulator, frame accumulator |
| `src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs` | Mode-specific demodulators: SSB, AM envelope, FM quadrature, CW envelope |
| `CONFIGURATION.md` | Document new `[rig.access] type = "sdr"`, `[sdr]`, `[[sdr.channels]]` options |
---
## 10. External Dependencies
| Crate | Purpose |
|-------|---------|
| `soapysdr` | Rust bindings to `libSoapySDR` (C++) |
| `num-complex` | `Complex<f32>` for IQ arithmetic |
System requirement: `libSoapySDR` installed (e.g. `brew install soapysdr` on macOS, `libsoapysdr-dev` on Debian/Ubuntu).
---
## 11. Validation Rules (startup)
- `[rig.access] type = "sdr"` requires `args` to be non-empty.
- `[sdr] sample_rate` must be non-zero.
- For every channel: `|center_offset_hz + channel.offset_hz| < sdr_sample_rate / 2`.
- Exactly one channel must have `stream_opus = true` (or none; zero means no TCP audio stream).
- The audio `sample_rate` in `[audio]` must equal the target audio rate in the SDR pipeline (no cross-rate mismatch).
- `[audio] tx_enabled` must be `false` when `model = "soapysdr"`.
- A decoder name may appear in at most one channel's `decoders` list.
- If the device does not support hardware AGC and `gain.mode = "auto"`, warn and fall back to `manual` using `gain.value`.
+41
View File
@@ -0,0 +1,41 @@
# Project Skills
Custom slash commands (skills) available in this repository.
Invoke with `/skill-name [args]` inside Claude Code.
---
## `/frontend-design` — HTTP frontend work
**When to use:** Any time you need to add or modify UI in the HTTP web frontend — new control rows, panels, visual polish, capability-gated elements, or JS behaviour wired to REST endpoints.
**What it loads:** Design system context (palette, layout primitives, patterns), key file paths, and coding conventions so Claude writes code that matches the existing UI without needing to re-read the style guide each time.
**File:** `.claude/commands/frontend-design.md`
**Example invocations**
```
/frontend-design Add a CW keyer speed row (wpm slider) that POSTs to /set_cw_wpm, shown only when capabilities.tx is true.
/frontend-design Polish the filters panel — align the bandwidth label with the FIR taps label and add a unit suffix to the slider readout.
/frontend-design Add a waterfall canvas below the signal meter that renders frequency vs. time from a new SSE stream.
```
---
## Adding new skills
Place a Markdown file in `.claude/commands/<skill-name>.md`.
Use `$ARGUMENTS` as the placeholder for user-supplied text.
Skills in `.claude/commands/` are project-scoped and not committed if `.claude/` is in `.gitignore`.
To make a skill part of the repo (shared with all contributors), add it to `aidocs/` as documentation and track the command file in version control by removing `.claude/` from `.gitignore` or adding a specific exception.
---
## Global skills (available in all projects)
| Skill | When to use |
|-------|------------|
| `frontend-design` | Also installed globally; project version takes precedence here |
| `keybindings-help` | Customise Claude Code keyboard shortcuts |
+180
View File
@@ -0,0 +1,180 @@
# UI Capability Gating
This document specifies how `trx-client`'s HTTP frontend adapts its controls to the capabilities of the connected rig backend. Devices such as SDR receivers expose filter controls but not TX controls; traditional transceivers are the reverse.
---
## Progress
> **For AI agents:** This section is the single source of truth for implementation status.
> Each task has a unique ID (e.g. `UC-01`), a status badge, a description, the files it touches, and any blocking dependencies.
>
> Status legend: `[ ]` not started · `[~]` in progress · `[x]` done · `[!]` blocked
### Foundational (parallel)
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-01 | `[x]` | Extend `RigCapabilities` with `tx`, `tx_limit`, `vfo_switch`, `filter_controls`, `signal_meter` bool flags | `src/trx-core/src/rig/state.rs` | — |
| UC-02 | `[x]` | Update capability declarations in all backends to set new flags | `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`, `trx-backend-ft450d/src/lib.rs`, `trx-backend-soapysdr/src/lib.rs` | UC-01 |
| UC-03 | `[x]` | Add `RigFilterState` struct; add `filter: Option<RigFilterState>` to `RigSnapshot`; populate from SDR rig state | `src/trx-core/src/rig/state.rs`, `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs` | — |
| UC-04 | `[x]` | Add `SetBandwidth`, `SetFirTaps` to `ClientCommand`; add mapping arms; update `rig_task.rs` to dispatch them | `src/trx-protocol/src/types.rs`, `mapping.rs`, `src/trx-server/src/rig_task.rs` | UC-03 |
### HTTP layer
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-05 | `[x]` | Add `/set_bandwidth` and `/set_fir_taps` HTTP endpoints | `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs` | UC-04 |
### Frontend
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-06 | `[x]` | Read `state.info.capabilities` on each SSE event; toggle visibility of TX controls, meter rows, VFO button, lock button | `assets/web/app.js` | UC-01, UC-02 |
| UC-07 | `[x]` | Add "Filters" control panel (bandwidth, FIR taps, CW tone Hz); show only when `capabilities.filter_controls` | `assets/web/index.html`, `assets/web/app.js` | UC-05, UC-06 |
### Tests
| ID | Status | Task | Files | Needs |
|----|--------|------|-------|-------|
| UC-08 | `[x]` | Unit tests: SDR backend declares `tx=false`, `filter_controls=true`; FT-817/450D declare `tx=true`, `filter_controls=false` | `src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs`, `trx-backend-ft817`, `trx-backend-ft450d` | UC-02 |
| UC-09 | `[x]` | Protocol round-trip test: `RigSnapshot` serialises `filter` field when `Some`, omits it when `None` | `src/trx-protocol/src/codec.rs` or `types.rs` | UC-03 |
---
## Goals
- All UI control groups are **shown/hidden purely from `RigCapabilities`** flags received in the initial `GET /status` and each SSE `status` event — no hard-coding per model name
- SDR backends show filter controls (bandwidth, FIR taps, CW tone); hide TX controls (PTT, power, TX limit, TX meters, TX audio)
- Transceiver backends show TX controls; hide filter controls
- Adding a new backend requires only setting the right capability flags — no frontend changes
---
## Non-Goals
- Per-channel filter control (multi-channel SDR tuning) — out of scope; only the primary channel is exposed here
- Dynamic capability changes at runtime (capability flags are set once at rig init and treated as static)
- Changing the rigctl or http-json frontends (HTTP frontend only)
---
## Capability Flags
### New flags added to `RigCapabilities` (UC-01)
| Flag | Type | Meaning |
|------|------|---------|
| `tx` | `bool` | Backend supports transmit: PTT, power on/off, TX meters, TX audio |
| `tx_limit` | `bool` | Backend supports `get_tx_limit` / `set_tx_limit` |
| `vfo_switch` | `bool` | Backend supports `toggle_vfo` |
| `filter_controls` | `bool` | Backend supports runtime filter adjustment (bandwidth, FIR taps) |
| `signal_meter` | `bool` | Backend returns a meaningful RX signal strength value |
Existing flags `lock` and `lockable` are unchanged.
### Backend declarations (UC-02)
| Backend | `tx` | `tx_limit` | `vfo_switch` | `filter_controls` | `signal_meter` | `lock`/`lockable` |
|---------|------|-----------|--------------|-------------------|----------------|-------------------|
| FT-817 | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ / ✓ |
| FT-450D | ✓ | ✓ | ✓ | ✗ | ✓ | ✓ / ✓ |
| SoapySDR | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ / ✗ |
---
## Filter State
### `RigFilterState` struct (UC-03)
Added to `trx-core/src/rig/state.rs`:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RigFilterState {
pub bandwidth_hz: u32, // Audio bandwidth of primary channel
pub fir_taps: u32, // FIR filter tap count
pub cw_center_hz: u32, // CW tone centre frequency (audio domain)
}
```
Added to `RigSnapshot`:
```rust
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<RigFilterState>,
```
The SDR backend populates this from the primary channel's live DSP state. All other backends leave it `None`.
---
## New Protocol Commands
### `ClientCommand` additions (UC-04)
```rust
SetBandwidth { bandwidth_hz: u32 },
SetFirTaps { taps: u32 },
```
`SetCwToneHz` already exists and is reused.
### Mapping (UC-04)
```rust
ClientCommand::SetBandwidth { bandwidth_hz } =>
RigCommand::SetBandwidth(bandwidth_hz),
ClientCommand::SetFirTaps { taps } =>
RigCommand::SetFirTaps(taps),
```
The SDR backend applies changes to the live DSP chain immediately. Other backends return `RigError::not_supported(...)`.
---
## New HTTP Endpoints (UC-05)
| Endpoint | Method | Query param | Action |
|----------|--------|-------------|--------|
| `/set_bandwidth` | POST | `hz: u32` | Sets primary channel audio bandwidth |
| `/set_fir_taps` | POST | `taps: u32` | Sets primary channel FIR tap count |
---
## Frontend Visibility Map (UC-06, UC-07)
| UI element / group | Shown when |
|--------------------|-----------|
| PTT button | `capabilities.tx` |
| Power button | `capabilities.tx` |
| TX meters (power bar, SWR bar) | `capabilities.tx && state.status.tx_en` |
| TX Limit row | `capabilities.tx_limit` |
| TX Audio toggle + volume | `capabilities.tx` |
| VFO selector buttons | `capabilities.vfo_switch` |
| Lock button | `capabilities.lock` |
| Signal meter | `capabilities.signal_meter` |
| Filters panel | `capabilities.filter_controls` |
Visibility is applied in a single `applyCapabilities(caps)` function called from the SSE `status` handler, using `element.classList.toggle('hidden', !condition)`.
### Filter panel layout (UC-07)
```
┌─ Filters ──────────────────────────────────┐
│ Bandwidth [──────●──────] 3000 Hz │
│ FIR taps [32 ▾] (32 / 64 / 128 / 256) │
│ CW tone [──●───────────] 700 Hz │
└────────────────────────────────────────────┘
```
Each control dispatches to its REST endpoint on `change`/`input` (debounced 200 ms). The panel is hidden by default (`class="hidden"`) and revealed when `capabilities.filter_controls` is set.
---
## Implementation Notes
- `applyCapabilities()` must run **before** the first paint (call it synchronously on the initial `/status` response, not only on SSE events) to avoid layout flash of unsupported controls.
- `hidden` CSS class should set `display: none` and `aria-hidden: true`.
- The existing `set_cw_tone` endpoint and CW decoder panel remain in the CW decoder tab — they are decoder settings, not filter settings. The Filters panel bandwidth/taps apply to the DSP chain; CW tone moves to both places or is de-duplicated in a follow-up.
- If a future backend supports TX but not `tx_limit`, only the TX Limit row is hidden; PTT remains.
+79
View File
@@ -0,0 +1,79 @@
# Canvas2D to WebGL Transition Plan
## Goal
- Replace all runtime Canvas2D rendering in the frontend with WebGL.
- Remove Canvas2D code paths after feature parity is reached.
- Keep existing interaction behavior (zoom/pan/tune/BW drag/tooltips/overlays) intact.
## Scope
- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js`
- `overview-canvas`
- `spectrum-canvas`
- `signal-overlay-canvas`
- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js`
- `cw-tone-waterfall`
- New shared WebGL utility module:
- `assets/web/webgl-renderer.js`
## Non-Goals
- No Canvas2D fallback path.
- No feature redesign outside rendering internals.
## Constraints
- Must preserve existing data flow and event wiring.
- Must keep map/decoder/bookmark integrations unchanged.
- Must remain dependency-free (no external rendering libraries).
## 2-Phase Migration
1. Phase 1 (Rendering engine insertion)
- Add shared WebGL renderer utility (primitives + textures + color parsing).
- Keep existing business logic and interaction handlers untouched.
- Swap draw targets from 2D contexts to WebGL primitives.
2. Phase 2 (Canvas2D removal and parity closure)
- Remove `getContext("2d")` usage from app and plugins.
- Remove obsolete 2D-specific cache paths.
- Validate behavior on resize/theme/style/stream reconnect/decoder mode changes.
## Parallel Workstreams ("Agents")
1. Agent A: Shared WebGL core
- Build `webgl-renderer.js` with:
- HiDPI resize handling
- Solid/gradient rects
- Polyline/segment/fill primitives
- RGBA texture upload + blit
- CSS color parser helpers
2. Agent B: Main spectrum/overview migration
- Port `drawSpectrum`, `drawHeaderSignalGraph`, `drawSignalOverlay`, and clear paths.
- Replace 2D offscreen waterfall cache with WebGL texture updates.
- Keep frequency axis/bookmark axis DOM behavior unchanged.
3. Agent C: CW tone picker migration
- Port `drawCwTonePicker` primitives to WebGL.
- Preserve auto/manual tone interactions and mode gating.
## Acceptance Criteria
- No frontend `getContext("2d")` usage remains.
- All four canvases render using WebGL and respond to resize/DPR changes.
- Spectrum interactions still work:
- wheel zoom
- drag pan
- BW edge drag
- click tune
- Overview strip continues showing waterfall/history.
- CW tone picker remains interactive and reflects current spectrum/tone.
## Verification Checklist
- Static:
- `rg -n 'getContext\\("2d"\\)' src/trx-client/trx-frontend/trx-frontend-http/assets/web`
- Runtime smoke:
- Open main tab: verify overview + spectrum + overlay.
- Toggle theme/style.
- Resize window and spectrum grip.
- Enable CW decoder and validate tone picker updates/click-to-set.
- Confirm no rendering exceptions in browser console.
## Rollout Notes
- Initial rollout is WebGL-only.
- If a browser lacks WebGL, canvases remain blank by design until a dedicated fallback policy is defined.
Submodule
+1
Submodule docs added at c98dc5ab75
-14
View File
@@ -1,14 +0,0 @@
# trx-rs
`trx-rs` is a modular amateur radio control stack written in Rust. It splits
hardware access, DSP, transport, and user-facing interfaces into separate
components so a radio or SDR can be controlled locally while audio, decoding,
and remote control are exposed elsewhere on the network.
## Documentation
- [User Manual](User-Manual) — configuration, features, and usage
- [Architecture](Architecture) — system design, crate layout, data flow, and internals
- [Optimization Guidelines](Optimization-Guidelines) — performance guidelines for the real-time DSP pipeline
- [Planned Features](Planned-Features) — planned features and design notes
- [Improvement Areas](Improvement-Areas) — codebase audit: quality, architecture, security, performance, and improvement plan
-211
View File
@@ -1,211 +0,0 @@
# Improvement Areas
A comprehensive audit of the trx-rs codebase covering code quality, architecture,
security, testing, and performance. Each item includes the affected location and
a suggested fix.
*Last updated: 2026-03-29*
---
## Resolved Items
<details>
<summary>Click to expand resolved items from previous audits</summary>
### Plugin signing and cross-platform validation — DROPPED
Plugin system has been removed from the codebase. No longer applicable.
### Session store mutex poisoning (auth.rs) — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/auth.rs`
All 6 `.write().unwrap()` / `.lock().unwrap()` calls replaced with
`.unwrap_or_else(|e| { warn!(...); e.into_inner() })` pattern. Lock poisoning now
logs a warning and recovers the inner data instead of crashing.
### No rate limiting on TCP listener — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Added `ConnectionTracker` with per-IP connection limiting (default: 10 concurrent
connections per IP). Connections exceeding the limit are rejected with a log warning.
Slots are released when clients disconnect.
### RigState is a 33-field flat struct — RESOLVED
**Location:** `src/trx-core/src/rig/state.rs`
Decoder fields grouped into `DecoderConfig` (8 bools) and `DecoderResetSeqs`
(8 u64 counters). Both use `#[serde(flatten)]` for backward-compatible JSON wire
format. Updated across all consumers.
### No `spawn_blocking` timeout — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Satellite pass computation wrapped in `tokio::time::timeout(30s, ...)` with
graceful fallback to empty results on timeout or panic.
### Command handler boilerplate — RESOLVED
**Location:** `src/trx-core/src/rig/controller/handlers.rs`
Created `rig_command!` declarative macro. 7 unit commands use the macro; 4 commands
with custom fields/validation remain as explicit impls.
### No command execution timeouts at CommandExecutor level — RESOLVED
**Location:** `src/trx-server/src/rig_task.rs`
`tokio::time::timeout(command_exec_timeout, process_command(...))` wraps all
command execution. Default timeout: 10s, configurable via `RigTaskConfig`.
### No forward compatibility in protocol — RESOLVED
**Location:** `src/trx-protocol/src/types.rs`, `src/trx-protocol/src/codec.rs`
Added optional `protocol_version: Option<u32>` to `ClientEnvelope` and
`ClientResponse`. `parse_envelope()` distinguishes malformed JSON from
unrecognised `cmd` values.
### `unsafe` string construction in spectrum encoding — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api.rs`
Replaced `unsafe { String::from_utf8_unchecked(out) }` with safe
`String::from_utf8(out).expect(...)`.
### `#[allow(dead_code)]` cleanup — RESOLVED
Reduced from 6 to 4 annotations, all in trx-backend-soapysdr where fields serve
as lifetime anchors (`device`, `iq_tx`) or document reserved capacity
(`fixed_slot_count`, `process_pair`).
### VDES decoder incomplete FEC — RESOLVED
Turbo FEC decoder, CRC-16-CCITT validation, and M.2092-1 link-layer frame parsing
implemented.
### Plugin system lacks versioning — DROPPED
Plugin system removed from the codebase.
### Configurator serial detection stubbed — RESOLVED
Implemented using `tokio_serial::available_ports()` with USB, Bluetooth, PCI, and
Unknown port type descriptions.
### Inconsistent frequency/rig naming — DOCUMENTED AS INTENTIONAL
Field names reflect distinct semantic contexts: `freq_hz` (dial), `center_hz`
(SDR capture center), `cw_center_hz` (CW tone); `rig_id` (config key), `id`
(runtime UUID); `model` (hardware string), `rig_model` (config parameter).
### Decoder task duplication in audio.rs — RESOLVED
**Location:** `src/trx-server/src/audio.rs`
APRS and HF APRS decoders merged into a single parameterised
`run_aprs_decoder_inner()` function. FT8 and FT4 decoders merged into
`run_ftx_decoder_inner()`. All decoder tasks now include `tracing::info_span!`
around `block_in_place()` calls for opt-in latency measurement.
### Missing tests for critical modules — RESOLVED
**Location:** `src/trx-server/src/listener.rs`, `src/trx-client/trx-frontend/trx-frontend-http/`
Added multi-rig state isolation and command routing tests in `listener.rs`.
Added background decode `evaluate_bookmark` pure-function tests.
### Missing integration tests for multi-rig scenarios — RESOLVED
**Location:** `src/trx-server/src/listener.rs`
Added integration tests covering simultaneous state management across two rigs
with a dummy backend, verifying state isolation and command routing.
### Decode log silent failures — RESOLVED
**Location:** `src/decoders/trx-decode-log/src/lib.rs`
`flush()` errors are now logged via `warn!`. On file rotation failure, the old
writer is kept rather than silently dropping writes; a degradation warning is
emitted.
### `api.rs` file size and organization — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/api/`
Split 2,831-LOC monolith into 7 logically grouped modules: `mod.rs` (shared
types and route configuration), `decoder.rs`, `rig.rs`, `vchan.rs`, `sse.rs`,
`bookmarks.rs`, `assets.rs`.
### Background decode state complexity — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/src/background_decode.rs`
Extracted the 8-guard decision cascade into a pure `evaluate_bookmark()` function
returning `ChannelAction` enum (`Active` or `Skip { reason }`). Added unit tests
for all decision paths.
### Actix-web pinned to exact version — RESOLVED
**Location:** `src/trx-client/trx-frontend/trx-frontend-http/Cargo.toml`
Relaxed from `actix-web = "=4.4.1"` to `actix-web = "4.4"` to allow patch-level
security updates.
### Magic numbers in VDES plausibility scoring — RESOLVED
**Location:** `src/decoders/trx-vdes/src/lib.rs`
Inline magic numbers replaced with documented named constants:
`PLAUSIBILITY_UNSYNCED_THRESHOLD` (35) and
`PLAUSIBILITY_LOW_CONFIDENCE_THRESHOLD` (15).
### FT-817 VFO inference fragile with same frequency — DOCUMENTED
**Location:** `src/trx-server/trx-backend/trx-backend-ft817/src/lib.rs`
When both VFOs share the same frequency, inference defaults to VFO A. Resolved
after VFO toggle primes both sides. Well-documented in code comments; remains
a known limitation.
### Excessive string cloning in remote client — RESOLVED
**Location:** `src/trx-client/src/remote_client.rs`
Hot-path spectrum polling loop now caches the token to avoid per-poll cloning.
State update path restructured to send to the main watch channel last (taking
ownership) and avoid one redundant `RigState::clone()`.
### Missing doc comments on public decoder structs — RESOLVED
**Location:** `src/decoders/trx-ais/src/lib.rs`, `src/decoders/trx-vdes/src/lib.rs`,
`src/decoders/trx-rds/src/lib.rs`
Added comprehensive doc comments to `AisDecoder`, `VdesDecoder`, and `RdsDecoder`
describing valid sample rates, usage examples, and reset semantics.
### Turbo decoder precondition not asserted — RESOLVED
**Location:** `src/decoders/trx-vdes/src/turbo.rs`
Added `debug_assert_eq!` on interleaver and deinterleaver lengths in
`turbo_decode_soft()`.
### No tracing spans for decoder performance — RESOLVED
**Location:** `src/trx-server/src/audio.rs`
Added `tracing::info_span!` around `block_in_place()` calls in all 10 decoder
tasks (APRS, HF APRS, AIS A/B, VDES, CW, FT8, FT4, FT2, WSPR, LRPT) for
opt-in per-decoder latency measurement.
</details>
---
All previous improvement items have been resolved. No outstanding issues.
-95
View File
@@ -1,95 +0,0 @@
# RDS Parameter Tuning Notes
*Decoder tuning rationale for `trx-rds`. Recorded 2026-03-27; reflects the
shipped parameter set. Kept as a reference for why these constants were chosen —
not an open work item.*
## Goal
Maximum sensitivity (weak-signal decode) with zero false positive PI decodes.
## Changes Applied
### `src/decoders/trx-rds/src/lib.rs`
#### Constants tuned
- `RRC_ALPHA = 0.50` (was 0.75) — narrower noise bandwidth, ~0.6 dB SNR gain
- `COSTAS_KI = 3.5e-7` — loop damping ζ≈0.68, well-damped (1e-6 caused instability)
- `PI_ACC_THRESHOLD = 3` (was 2) — accumulate 3 Block A observations before committing PI
- `OSD_MAX_FLIP_COST = 0.45` — Tech 9: reject OSD corrections where flipped bits had
high confidence (genuine errors have cost ≲ 0.3; noise matches cost 0.61.2)
#### Soft confidence fix
In `Candidate::process_sample`, the soft confidence passed to `push_bit_soft` is now
`biphase_i.abs()` (was full vector magnitude). This aligns confidence with the bit
decision sign and prevents OSD(2) from false-decoding noise when the Costas loop
has residual phase error.
#### OSD(2) in locked mode (kept)
`decode_block_soft` performs OSD(2): hard decode → all 26 single-bit flips → all
325 two-bit flip pairs. Only active in locked mode; sequential B→C→D block-type
gating limits false positives.
#### Search mode: hard decode only
Removed OSD(1) from Block A acquisition (search mode). With OSD(1), ~13% of
random 26-bit words would falsely pass the Block A test per bit, allowing wrong
clock-phase candidates to accumulate false groups as fast as the correct candidate
accumulates real ones. Hard decode reduces the false Block A rate to ~0.5%.
#### Tech 9: OSD cost ceiling
`decode_block_soft` now enforces `OSD_MAX_FLIP_COST = 0.45` — the sum of soft
confidences for all flipped bits must not exceed this threshold. At 910 dB SNR,
genuine bit errors have very low `|biphase_I|` (cost ≲ 0.3), while noise-induced
OSD matches flip high-confidence bits (cost 0.61.2). This eliminates most
spurious OSD(2) matches without affecting real weak-signal corrections.
#### Tech 10: PI consistency gate
`process_group` rejects groups whose Block A PI differs from the candidate's
established PI. This prevents a single false OSD decode from polluting accumulated
text fields (PS, RT, PTYN) with garbage from noise or interference.
#### Candidate selection: incumbent tracking
Added `best_candidate_idx: Option<usize>` to `RdsDecoder`. The incumbent (winning)
candidate can always update `best_state` at equal score (its `ps_seen`/`rt_seen`
arrays accumulate coherently). A challenger must achieve a strictly higher score to
take over. The incumbent's `best_score` is also updated when it returns `None`
(no state change) so challengers cannot leapfrog with a single false group.
#### Test fixes
- `blocks_to_chips`: added NRZI (NRZ-Mark) pre-encoding. The differential biphase
decoder computes `bit = input_bit XOR prev_input_bit`; without NRZI the recovered
bits were XOR-of-consecutive-bits, not the original data.
- `decode_block_soft_rejects_three_bit_error`: removed (OSD(2) legitimately finds
distance-2 codewords; `pure_noise_produces_zero_pi_decodes` is the real guard).
- New test: `blocks_to_chips_round_trips_all_groups` — verifies round-trip decode
of all 16 blocks across all 4 PS segments without BPSK modulation.
### `src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/wfm.rs`
- `PILOT_LOCK_THRESHOLD = 0.20` (was 0.25) — pilot reference enabled at lower coherence
- Added `PILOT_LOCK_ONSET = 0.30` constant (was hardcoded 0.4)
- `pilot_lock` ramp: `((pilot_coherence - PILOT_LOCK_ONSET) / 0.2).clamp(0.0, 1.0)`
— pilot reference engages at coherence ≥ 0.36 instead of ≥ 0.45
## Test Status
```
cargo test -p trx-rds
```
16/16 passing:
- ✅ decode_block_recognizes_valid_offsets
- ✅ decode_block_soft_corrects_single_bit_error
- ✅ decode_block_soft_corrects_two_bit_error_osd2
- ✅ block_decode_rate_osd1_vs_osd2
- ✅ decode_block_soft_prefers_least_costly_flip
- ✅ full_group_with_two_bit_errors_in_each_locked_block
- ✅ pi_accumulation_corrects_weak_pi_after_threshold
- ✅ decoder_emits_ps_and_pty_from_group_0a
- ✅ rrc_tap_dc_gain
- ✅ pure_noise_produces_zero_pi_decodes (2 seconds of noise, zero false PI)
- ✅ end_to_end_with_pilot_reference_decodes_pi
- ✅ end_to_end_noisy_signal_snr_10db_decodes_pi
- ✅ end_to_end_noisy_signal_snr_9db_decodes_pi ← new, 9 dB threshold
- ✅ costas_tracks_without_diverging_on_clean_signal
- ✅ blocks_to_chips_round_trips_all_groups
- ✅ end_to_end_clean_signal_decodes_ps
-163
View File
@@ -1,163 +0,0 @@
# Settings Menu — UI/UX Analysis & Improvement Plan
*Authored: 2026-03-30*
## 1. Current Structure
The Settings tab (`#tab-settings`) contains four sub-tabs:
| Sub-tab | Purpose | Complexity |
|---|---|---|
| **Scheduler** | Grayline / Time Span / Satellite scheduling | High — nested modes, forms, timeline |
| **Background Decode** | Hidden background decoder channels | Medium — toggle + bookmark checklist |
| **Bandplan** | IARU region overlay on spectrum | Low — dropdown + checkbox |
| **History** | Clear server-side decode history | Low — 10 clear buttons |
---
## 2. Identified Issues
### 2.1 Information Architecture
| # | Issue | Severity |
|---|---|---|
| IA-1 | **"Settings" is a catch-all bucket.** Scheduler and Background Decode are operational features, not user preferences. Bandplan and History are true settings/maintenance. Mixing them under one tab creates cognitive overhead. | Medium |
| IA-2 | **Scheduler sub-tab is overloaded.** It packs three conceptually distinct features (Grayline, Time Span, Satellite) into one scrollable panel via conditional `display:none` sections. Users must scroll past irrelevant sections. | Medium |
| IA-3 | **History clearing is buried.** Users wanting to clear FT8 decode history must navigate to Settings → History — an unintuitive path. This action is more naturally accessible from the Digital Modes tab itself. | Low |
| IA-4 | **No search or categorization.** With 4 sub-tabs today, it's manageable, but the flat sub-tab bar won't scale if more settings (e.g., audio, display theme, reporting/PSKReporter, notifications) are added. | Low |
### 2.2 Interaction Design
| # | Issue | Severity |
|---|---|---|
| IX-1 | **Save button visibility is inconsistent.** Save/Reset buttons use `style="display:none"` and are shown dynamically, but there is no dirty-state indicator. Users can change fields without realizing they haven't saved. | High |
| IX-2 | **No confirmation on destructive actions.** The 10 history-clear buttons and "Reset to Disabled" (scheduler) fire immediately on click. No confirmation dialog protects against accidental data loss. | High |
| IX-3 | **Entry table details collapsed by default.** The Time Span entry table is inside a `<details>` element — users must expand it to see, edit, or delete entries. This adds an unnecessary click when entries already exist. | Medium |
| IX-4 | **Satellite form uses a modal overlay; Time Span form is inline.** Inconsistent form presentation within the same sub-tab. Both should use the same pattern. | Medium |
| IX-5 | **Toast notification positioning.** The `.sch-toast` uses `position: fixed; bottom: 1.5rem` which can overlap with the main tab bar or mobile navigation. It also disappears without user control. | Low |
| IX-6 | **Bookmark filter in Background Decode has no "select all / deselect all" shortcut.** With many bookmarks, toggling them one by one is tedious. | Medium |
### 2.3 Visual & Layout
| # | Issue | Severity |
|---|---|---|
| VL-1 | **Scheduler has no visual state summary.** The "No activity yet." card doesn't show whether the scheduler is enabled or what mode it's in at a glance. Users must inspect the mode dropdown. | Medium |
| VL-2 | **History clear buttons are uniform.** All 10 buttons look identical (`sch-write sch-reset-btn`). No indication of which decoders have data to clear. Buttons for empty histories are noise. | Low |
| VL-3 | **Mobile responsiveness is partial.** The `@media (max-width: 600px)` rules handle `.sch-row` and `.bgd-*` layout, but the Time Span table (`.sch-ts-table` with 8 columns) overflows on narrow screens. | Medium |
| VL-4 | **Sub-tab bar can overflow.** It uses `overflow-x: auto` but gives no visual scroll indicator. On small screens, the "History" tab can be hidden off-screen with no affordance. | Low |
### 2.4 Accessibility
| # | Issue | Severity |
|---|---|---|
| A-1 | **Missing `aria-label` on several controls.** The scheduler mode select has one, but the grayline lat/lon inputs, interleave fields, and satellite fields lack accessible names beyond their visible label text (which is acceptable for `<label>` wrapping `<input>`, but form titles like "Add Entry" aren't linked to the form via `aria-labelledby`). | Low |
| A-2 | **No keyboard navigation for the 24h timeline SVG.** Timeline segments are clickable (`cursor: pointer`) but not focusable or keyboard-operable. | Medium |
| A-3 | **Color-only state indication in Background Decode status.** States like "active" (green), "waiting" (yellow), "error" (red) rely solely on color. Not sufficient for color-blind users. | Medium |
| A-4 | **Toast notifications aren't announced to screen readers.** The `.sch-toast` div lacks `role="alert"` or `aria-live` attributes. | Low |
---
## 3. Improvement Plan
### Phase 1 — Quick Wins (Low effort, high impact)
```mermaid
gantt
title Phase 1 — Quick Wins
dateFormat X
axisFormat %s
section Interaction
IX-2 Add confirmation dialogs :a1, 0, 2
IX-6 Select all / deselect all :a2, 0, 1
IX-1 Dirty-state indicator on Save :a3, 0, 2
section Accessibility
A-4 Add aria-live to toasts :a4, 0, 1
A-3 Add text labels to state dots :a5, 0, 1
```
**IX-2: Add confirmation dialogs for destructive actions**
- Wrap history-clear and "Reset to Disabled" clicks in a `confirm()` dialog (or a lightweight inline confirmation pattern).
- Estimated: ~30 lines of JS.
**IX-6: Add select all / deselect all for Background Decode bookmarks**
- Add two small buttons above the bookmark checklist: "Select All" / "Deselect All".
- Alternatively, a single toggle that reads the current state.
**IX-1: Dirty-state indicator**
- Track whether any field has changed since last load/save.
- Show a visual cue (e.g., dot on the Save button, or change button color) when there are unsaved changes.
- Optionally warn on tab navigation away from dirty settings.
**A-4: Toast accessibility**
- Add `role="alert"` and `aria-live="polite"` to `.sch-toast` elements.
**A-3: State badge text labels**
- The `.bgd-status-state` already shows uppercase text — ensure the SVG dot badges (`.bgd-state-dot`) are supplemented with visible text, not just color.
---
### Phase 2 — Structural Improvements (Medium effort)
**IA-1 + IA-3: Reorganize the Settings tab**
Proposed new sub-tab structure:
| Sub-tab | Contents |
|---|---|
| **Scheduler** | Grayline, Time Span, Satellite (unchanged) |
| **Background Decode** | Background decode config (unchanged) |
| **Display** | Bandplan region/labels, future: theme, font size, spectrum colors |
| **Maintenance** | History clearing, with per-decoder item counts |
Additionally, add contextual "Clear history" links directly in the Digital Modes tab (next to each decoder's output panel), so users don't need to navigate to Settings at all for this common action.
**IX-3: Auto-expand entry table when entries exist**
- If `scheduler-ts-tbody` has rows, set the `<details>` element's `open` attribute on render.
**IX-4: Unify form presentation**
- Convert the satellite modal (`#sch-sat-form-wrap` with `position: fixed`) to an inline form matching the Time Span entry form pattern, or vice versa. Inline is preferred for consistency and mobile friendliness.
**VL-1: Scheduler status summary card**
- Enhance the "Now Playing" card to always show: current mode, active entry (if any), next scheduled event, and satellite pass countdown (if enabled).
- Use a compact two-line format when idle: "Mode: Grayline | Next: Dawn transition in 2h 14m".
**VL-3: Responsive table for Time Span entries**
- Replace the 8-column table with a card-based layout on narrow screens (`@media (max-width: 600px)`), or use horizontal scroll with a scroll shadow indicator.
**A-2: Keyboard-accessible timeline**
- Add `tabindex="0"` and `role="button"` to timeline segments.
- Handle `keydown` for Enter/Space to activate.
---
### Phase 3 — Polish & Scalability (Higher effort)
**VL-2: Smart history-clear buttons**
- Query each decoder's item count via API (or piggyback on existing SSE state).
- Show count badges on each button (e.g., "Clear FT8 history (142)").
- Disable or hide buttons for decoders with no history.
- Add a "Clear All" button with appropriate confirmation.
**IA-4: Settings search (future-proofing)**
- If the settings surface grows beyond 5-6 sub-tabs, add a search/filter input at the top of the Settings tab that highlights matching sections.
- Not needed today, but the sub-tab architecture should be designed to accommodate it.
**VL-4: Sub-tab scroll indicators**
- Add CSS gradient fade or arrow indicators when the sub-tab bar overflows horizontally.
- Consider a "more" dropdown for narrow viewports.
**IX-5: Improved toast system**
- Position toasts inside the settings panel (not `position: fixed`) to avoid overlap with global UI.
- Add a brief auto-dismiss with a progress bar, plus a manual dismiss button.
- Stack multiple toasts if needed.
---
## 4. Priority Summary
| Priority | Items | Rationale |
|---|---|---|
| **P0 — Do Now** | IX-2 (confirmations), IX-1 (dirty state) | Prevent accidental data loss |
| **P1 — Next** | IX-6 (select all), A-3 (color-blind), A-4 (toast a11y), IX-3 (auto-expand) | Low effort, meaningful UX gains |
| **P2 — Soon** | IA-1/IA-3 (reorg), IX-4 (form consistency), VL-1 (status card), VL-3 (mobile table) | Structural quality |
| **P3 — Later** | VL-2 (smart buttons), IA-4 (search), VL-4 (scroll hints), IX-5 (toast rework) | Polish and future-proofing |
-390
View File
@@ -1,390 +0,0 @@
# UX Guidelines
This document captures the UI/UX design patterns, conventions, and principles observed across
the trx-rs application. It covers the web frontend, CLI interfaces, configuration wizard, API
design, and error handling.
*Last reviewed: 2026-03-28*
---
## 1. Web Frontend (trx-frontend-http)
### 1.1 Layout and Navigation
The web UI is a single-page application served from embedded assets (no build step). It uses
a **tab-based** navigation model with six top-level tabs:
| Tab | Icon | Purpose |
|---|---|---|
| **Main** | House | Primary radio control: spectrum, frequency, mode, PTT, VFO, SDR controls |
| **Bookmarks** | Bookmark | Saved frequency/mode presets with folder organisation |
| **Digital modes** | Bar chart | FT8/FT4/FT2, WSPR, CW, APRS, AIS, VDES decode tables |
| **Map** | Pin | Leaflet map for APRS/AIS/FT8 station plotting |
| **Settings** | Wrench | Scheduler, background decode, history retention |
| **About** | Info circle | Server/client/radio/audio/decoder/integration details |
Tabs use inline SVG icons with a text label below. On narrow viewports the tab bar wraps and
subtitles collapse to save space.
The **Settings** and **About** tabs each use a secondary **sub-tab bar** for further grouping
(e.g. Settings > Scheduler | Background Decode | History).
### 1.2 Theming
The UI supports **dark mode** (default) and **light mode** toggled via a header button. Theme
preference persists in `localStorage`.
Additionally, nine **colour styles** are available via a dropdown:
- Original (default), Arctic, Lime, Contrast, Neon Disco, Donald (golden-rain), Amber, Fire, Phosphor
Each style provides a full CSS custom-property override set for both dark and light variants.
Styles are applied via `data-style` and `data-theme` attributes on `<html>`.
All colours reference CSS custom properties (`--bg`, `--card-bg`, `--text`, `--accent-green`,
`--border-light`, etc.) so components never use hard-coded colour values.
### 1.3 Typography
- **Body**: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`
- **Frequency display**: `DSEG14 Classic` (14-segment display font, loaded from CDN with `preload`)
- **Labels**: uppercase, 0.68-0.78 rem, `font-weight: 700`, `letter-spacing: 0.04em`
- **Section labels** use pill-shaped badges (`border-radius: 999px`) with muted text
### 1.4 Responsive Design
Six breakpoints handle layout adaptation:
| Breakpoint | Behaviour |
|---|---|
| `> 1100px` | Full width with bookmark side gutters on spectrum |
| `< 1100px` | Side bookmark panels hidden |
| `< 900px` | Card fills viewport width, reduced padding |
| `< 760px` | Tab bar wraps, controls stack vertically, safe-area-inset padding for notched devices |
| `< 640px` | Bottom-fixed tab bar (mobile), subtitles hidden, compact header |
| `< 520px` | Further compact adjustments |
Touch-specific: `@media (hover: none) and (pointer: coarse)` enlarges hit targets.
The spectrum panel hints adapt: mouse users see "Scroll to zoom / Ctrl+Scroll to tune /
Drag to pan" while touch users see "Pinch to zoom / Drag to pan".
### 1.5 Interactive Controls
- **Jog wheel**: Circular CSS-styled draggable dial for frequency tuning (skeuomorphic radial-gradient, grab cursor, shadow/inset). Plus/minus buttons flank it.
- **Step unit buttons**: Segmented button group (MHz / kHz / Hz) with `.active` highlight
- **Step scale**: 1x / 0.1x multiplier toggle
- **Frequency input**: Monospace DSEG14 font, editable `<input>` with disabled opacity fix
- **Mode selector**: `<select>` dropdown populated from rig capabilities
- **PTT / Power / Lock buttons**: Three-column grid in the transmit/power section
- **VFO picker**: Button group (horizontal on desktop, vertical stack on mobile)
- **WFM/SAM controls**: Compact labelled controls (de-emphasis, audio mode, denoise, stereo pilot flag, CCI/ACI interference bars)
- **SDR settings row**: AGC checkbox, RF/LNA gain inputs with Set buttons, noise blanker
### 1.6 Spectrum and Waterfall
The spectrum panel uses `<canvas>` elements (WebGL renderer optional) and offers:
- **Drag to pan**, **scroll to zoom**, **Ctrl+scroll to tune**
- Bandwidth edges are draggable to resize the filter
- Keyboard shortcuts: `+`/`-` zoom, arrows pan, `0` reset
- **Minimap** for orientation when zoomed
- **Resize grip** to adjust spectrum height
- Controls: bandwidth input, auto-BW, sweet-spot, peak hold (0-60s), floor (dB), range (dB), auto-level, contrast gamma slider
- **Waterfall/waveform split slider** (20%-80%, default 50/50)
- **Bookmark axis** overlays on left/right sides at wider viewports
- **Decoder overlays**: RDS station name, AIS/VDES/FT8/APRS/CW bar overlays using `aria-live="polite"`
### 1.7 Real-Time Data
- **SSE (Server-Sent Events)** on `/events` for rig state updates. Each SSE session gets a
UUID, enabling per-tab rig selection without interfering with other tabs.
- **Named events**: `data` (state), `session` (session UUID), `channels` (virtual channels),
`b` (spectrum bins as base64), `rds`, `vchan_rds`, `ping` (5-second heartbeat)
- **WebSocket** on `/audio` for Opus-encoded RX audio streaming
- **Connection lost banner**: `#server-lost-banner` with pulsing dot, text "trx-server
connection lost -- waiting for reconnect", uses `aria-live="assertive"`
- **Loading state**: Centered "Initializing (rig)..." with subtitle, content hidden until ready
### 1.8 Accessibility
- All interactive elements have `aria-label` attributes
- Spectrum overlays use `aria-live="polite"` for screen reader announcements
- Connection-lost banner uses `aria-live="assertive"`
- `aria-hidden="true"` on decorative canvases and visual-only elements
- SVG icons include `aria-hidden="true"` with descriptive labels on parent buttons
- Spectrum resize grip has both `title` and `aria-label`
### 1.9 Authentication UX
When auth is enabled, an **auth gate** blocks the UI with:
- Title: "Access Required"
- Subtitle: "Enter passphrase to continue"
- Password input + Login button (green accent, full-width)
- Optional "Continue as Guest" button (shown when RX passphrase is not set)
- Error message area (red `#ff6b6b`)
- Role badge display
Two roles: **Rx** (read-only) and **Control** (full access including TX/PTT).
Session cookie: `trx_http_sid`, HttpOnly, configurable Secure and SameSite attributes.
The header shows a Login/Logout button when auth is enabled (`#header-auth-btn`).
### 1.10 Multi-Rig Support
- **Header rig switcher**: `<select>` dropdown in the top bar for switching between connected rigs
- Per-tab rig binding: each SSE session independently selects a rig via `?remote=` query parameter
- Rig state isolation: only the disconnected rig shows the connection-lost banner
- About tab shows active rig, available rigs list
---
## 2. REST API Design
### 2.1 Conventions
- **Read operations** use `GET` (e.g. `/status`, `/events`, `/decode/history`, `/rigs`, `/bookmarks`)
- **Mutations** use `POST` for actions and toggles (e.g. `/set_freq`, `/toggle_power`, `/toggle_ft8_decode`)
- **CRUD resources** use proper verbs: `GET /bookmarks`, `POST /bookmarks`, `PUT /bookmarks/{id}`,
`DELETE /bookmarks/{id}`
- **Batch operations**: `POST /bookmarks/batch_delete`, `POST /bookmarks/batch_move`
- **Nested resources**: `/channels/{remote}/{channel_id}/subscribe`, `/scheduler/{remote}/status`
- Responses are JSON with `Content-Type: application/json`
- SSE stream uses `Content-Type: text/event-stream` with `no-cache` and `keep-alive` headers
### 2.2 Request Timeout
All rig command requests have a **15-second timeout** (`REQUEST_TIMEOUT`). If the command
doesn't complete in time, the request returns an error rather than hanging.
### 2.3 Error Responses
- `401 Unauthorized`: `{"error": "Invalid credentials"}` or `{"error": "Authentication required"}`
- `429 Too Many Requests`: `{"error": "Too many login attempts, please try again later"}`
- `404 Not Found`: Auth endpoints when auth is disabled
- `500 Internal Server Error`: Serialization failures
- Rate limiting: 10 attempts per 60-second window per IP, counter resets on successful login
### 2.4 State Enrichment
API responses merge rig state with **frontend metadata** (`FrontendMeta`) via `serde(flatten)`:
```
http_clients, rigctl_clients, audio_clients, rigctl_addr,
active_remote, remotes[], owner_callsign, owner_website_url,
owner_website_name, ais_vessel_url_base, show_sdr_gain_control,
initial_map_zoom, spectrum_coverage_margin_hz, spectrum_usable_span_ratio,
decode_history_retention_min, server_connected
```
This single-payload approach avoids extra round trips for UI configuration.
---
## 3. CLI Interface
### 3.1 Argument Style
Both `trx-server` and `trx-client` use **clap** for argument parsing with short and long flags:
```
-C, --config FILE Path to configuration file
--print-config Print example configuration and exit
-r, --rig NAME Rig backend name
-l, --listen ADDR Listen address
-p, --port NUM Port number
```
Positional arguments are used sparingly (e.g. `RIG_ADDR` for serial/TCP address).
### 3.2 Configuration Resolution
Config files are searched in priority order:
1. Current directory: `trx-rs.toml`
2. XDG config: `~/.config/trx-rs/trx-rs.toml`
3. System: `/etc/trx-rs/trx-rs.toml`
The loaded config path is logged: `INFO Loaded configuration from /path/to/config.toml`
### 3.3 Example Config Generation
`--print-config` outputs a complete, commented TOML file to stdout with example values
(callsign `N0CALL`, coordinates `52.2297, 21.0122`). Each section has a header comment and
each field has an inline description.
### 3.4 Startup Log Sequence
Server:
```
INFO Loaded configuration from /path/to/config.toml
INFO Starting trx-server with N rig(s): [rig-names]
INFO Callsign: CALL
INFO [rig-id] Starting (rig: ft817, access: serial /dev/ttyUSB0 @ 9600 baud)
INFO Listening on 0.0.0.0:4530
```
Client:
```
INFO Loaded configuration from /path/to/config.toml
INFO Starting trx-client (remotes: [remote-names], frontends: http,rigctl)
INFO rigctl frontend for rig 'default' on 127.0.0.1:4532
```
---
## 4. Configuration Wizard (trx-configurator)
### 4.1 Interactive Mode
Uses the **dialoguer** crate for terminal prompts:
- `Select` menus for enumerated choices (config type, rig model, access type, log level)
- `Input` for free-text with defaults (callsign defaults to `N0CALL`, listen defaults to `127.0.0.1`)
- `Confirm` for yes/no questions (enable auth, set location, etc.)
- Serial port auto-detection with fallback to `/dev/ttyUSB0`
### 4.2 Non-Interactive Mode
`--defaults` generates a config file without prompts, using sensible defaults.
### 4.3 Config Validation
`--check FILE` validates an existing config file:
```
/path/to/config.toml: valid TOML
Detected type: server
warning: [general].log_level 'verbose' is invalid (expected: trace, debug, info, warn, error)
1 warning(s), 0 error(s)
```
Validates: TOML syntax, unknown keys, log levels, coordinate ranges (-90..90 lat, -180..180 lon
with pair requirement), access types, port ranges (0-65535).
### 4.4 File Write Confirmation
Prompts before overwriting an existing file. Outputs `Wrote /path/to/file` on success.
---
## 5. Error Handling and User-Facing Messages
### 5.1 Error Message Conventions
- **Contextual**: Include file paths, section names, and peer addresses
- `"Failed to parse config file /path: error details"`
- `"Unknown rig model: X (available: ft817, ft450d, soapysdr)"`
- **Actionable**: Suggest alternatives when available
- `"Rig model not specified. Use --rig or set [rig].model in config."`
- `"Unknown frontend: X (available: http, rigctl, httpjson)"`
- **Structured**: Use field=value format in structured logging
### 5.2 Log Level Guidelines
| Level | Usage |
|---|---|
| `INFO` | Startup milestones, configuration loaded, listening, client connect/disconnect, decoder state changes |
| `WARN` | Non-fatal issues: command took too long, panel lock blocking, VFO priming failed, initial tune failed |
| `ERROR` | Fatal or significant failures: CAT polling errors, client errors, parse failures |
Logs suppress module targets (`with_target(false)`) for cleaner output.
### 5.3 Connection State Communication
- Server logs: `"Client connected: {peer}"`, `"Client {peer} disconnected"`, `"Client {peer} closing due to shutdown"`
- Rig task: `"[rig-id] Rig backend ready"`, `"Serial: /dev/ttyUSB0 @ 9600 baud"`
- Web UI: Connection-lost banner with reconnect indication, per-rig isolation
### 5.4 Graceful Degradation
- Startup continues after non-fatal failures: `"Initial PowerOn failed (continuing)"`
- Stream errors are deduplicated with 60-second summaries to avoid log flooding
- Lock poisoning is recovered from rather than panicking
- Unknown SSE events or lagged broadcast channels are silently skipped
---
## 6. Branding and Customisation
### 6.1 Owner Branding
Configurable via TOML and exposed via `FrontendMeta`:
- `owner_callsign` -- displayed in header subtitle and About tab
- `owner_website_url` / `owner_website_name` -- optional link in header
- `ais_vessel_url_base` -- base URL for linking AIS vessel MMSI numbers
### 6.2 UI Behaviour Configuration
- `http_show_sdr_gain_control` -- show/hide RF gain controls
- `http_initial_map_zoom` -- default map zoom level
- `http_spectrum_coverage_margin_hz` -- guard margin for spectrum center retune
- `http_spectrum_usable_span_ratio` -- fraction of spectrum span treated as usable
- `http_decode_history_retention_min` -- default history retention (per-rig overrides supported)
### 6.3 Embedded Assets
Logo and favicon are embedded at compile time via `include_bytes!`. The logo image has an
`onerror` handler to hide itself if loading fails (`this.style.display='none'`).
---
## 7. Security UX
### 7.1 Route Access Classification
Routes are classified into three tiers:
| Tier | Examples | Requirement |
|---|---|---|
| **Public** | `/`, `/index.html`, `/map`, `/auth/*`, static assets | None |
| **Read** | `/status`, `/events`, `/audio`, `/decode`, `/spectrum`, `/bookmarks` | Rx or Control role |
| **Control** | `/set_freq`, `/set_mode`, `/set_ptt`, `/toggle_power`, all other POST | Control role only |
### 7.2 Session Management
- Sessions are 128-bit random hex tokens stored in HttpOnly cookies
- Configurable TTL (default from TOML config)
- Expired sessions auto-pruned on access
- Constant-time passphrase comparison to mitigate timing attacks
### 7.3 TX Access Control
An additional `tx_access_control_enabled` flag can restrict transmit-related actions even
for Control-role users, providing an extra safety layer.
---
## 8. Virtual Channels (SDR)
Virtual channels allow SDR users to monitor multiple frequencies simultaneously:
- Channels appear in a picker row below the VFO controls
- CRUD API: `POST /channels/{remote}` to create, `DELETE` to remove, `PUT` to update freq/mode/BW
- Subscribe/unsubscribe audio per channel
- Background decode channels (hidden, no audio stream back)
- Channels auto-destroyed when out-of-bandwidth after center-frequency retune
- Channel-list changes broadcast to SSE clients via `event: channels`
---
## 9. Design Principles (Inferred)
1. **Server-rendered SPA**: All HTML/CSS/JS embedded in the binary -- zero external build tooling, no CDN dependency for core functionality (CDN used only for fonts and Leaflet maps).
2. **Progressive disclosure**: Advanced controls (WFM, SAM, SDR settings, spectrum controls) are hidden by default and revealed based on the active mode and backend type.
3. **Keyboard-first, touch-aware**: Spectrum supports full keyboard navigation alongside mouse and touch gestures. Mobile breakpoints enlarge hit targets and adapt layout.
4. **Real-time by default**: SSE + WebSocket provide sub-second state updates without polling from the browser. 5-second ping heartbeat detects stale connections.
5. **Per-tab isolation**: Each browser tab gets its own SSE session UUID and can independently select a rig, preventing cross-tab interference.
6. **Configuration over code**: UI behaviour knobs (gain visibility, map zoom, history retention, spectrum margins) are exposed as TOML config rather than requiring code changes.
7. **Graceful degradation**: The UI handles server disconnection gracefully with visible banners, and only the affected rig shows as disconnected in multi-rig setups.
8. **Defensive security defaults**: Auth disabled by default for ease of setup, but when enabled, provides role-based access, rate limiting, constant-time comparison, and HttpOnly cookies.
-546
View File
@@ -1,546 +0,0 @@
# trx-rs Manual
## What trx-rs is
`trx-rs` is a modular amateur radio control stack written in Rust. It splits
hardware access, DSP, transport, and user-facing interfaces into separate
components so a radio or SDR can be controlled locally while audio, decoding,
and remote control are exposed elsewhere on the network.
In practice, `trx-server` owns the rig or SDR backend and runs the DSP
pipeline, while `trx-client` connects to it and provides frontends such as the
web UI, JSON control, and rigctl-compatible access. The workspace also includes
protocol decoders and plugin-based extension points for adding backends and
frontends.
---
## Configuration
Both `trx-server` and `trx-client` use TOML configuration files. Use
`--print-config` to generate a fully commented example.
### File Locations
**trx-server** lookup order:
1. `--config <FILE>`
2. `./trx-server.toml`
3. `~/.trx-server.toml`
4. `~/.config/trx-rs/server.toml`
5. `/etc/trx-rs/server.toml`
**trx-client** lookup order:
1. `--config <FILE>`
2. `./trx-client.toml`
3. `~/.config/trx-rs/client.toml`
4. `/etc/trx-rs/client.toml`
CLI arguments override config file values.
### Environment Variables
- `TRX_PLUGIN_DIRS`: additional plugin directories (path-separated), used by
both server and client.
### Server Options
#### `[general]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `callsign` | string | `"N0CALL"` | Station callsign |
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
| `latitude` | float | — | Station latitude (-90..90) |
| `longitude` | float | — | Station longitude (-180..180) |
`latitude` and `longitude` must be set together or both omitted.
#### `[rig]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string | — | Backend name (`ft817`, `ft450d`, `soapysdr`) |
| `initial_freq_hz` | u64 | `144300000` | Startup frequency (must be > 0) |
| `initial_mode` | string | `"USB"` | Startup mode |
#### `[rig.access]`
| Field | Type | Description |
|-------|------|-------------|
| `type` | string | `serial`, `tcp`, or `sdr` |
| `port` | string | Serial port path (serial mode) |
| `baud` | u32 | Serial baud rate (serial mode) |
| `host` | string | Remote host (tcp mode) |
| `tcp_port` | u16 | Remote port (tcp mode) |
| `args` | string | SoapySDR device args (sdr mode, e.g. `"driver=rtlsdr"`) |
#### `[behavior]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `poll_interval_ms` | u64 | `500` | Rig polling interval |
| `poll_interval_tx_ms` | u64 | `100` | Polling interval during TX |
| `max_retries` | u32 | `3` | Connection retry limit |
| `retry_base_delay_ms` | u64 | `100` | Base retry delay |
#### `[listen]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable JSON TCP listener |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4530` | Bind port |
#### `[listen.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `tokens` | string[] | `[]` | Allowed auth tokens (empty = no auth) |
#### `[audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio streaming |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4531` | Bind port |
| `rx_enabled` | bool | `true` | Enable RX audio |
| `tx_enabled` | bool | `true` | Enable TX audio |
| `device` | string | — | CPAL device name (empty = default) |
| `sample_rate` | u32 | `48000` | Sample rate (8000192000) |
| `channels` | u8 | `1` | Channel count (1 or 2) |
| `frame_duration_ms` | u16 | `20` | Opus frame duration (3, 5, 10, 20, 40, 60) |
| `bitrate_bps` | u32 | `24000` | Opus bitrate |
When audio is enabled, at least one of `rx_enabled` or `tx_enabled` must be true.
#### `[sdr]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `sample_rate` | u32 | `1920000` | IQ capture rate in Hz |
| `bandwidth` | u32 | `1500000` | Hardware IF filter bandwidth in Hz |
| `center_offset_hz` | i64 | `100000` | Offset from dial to avoid DC spur |
#### `[sdr.gain]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `mode` | string | `"auto"` | `"auto"` (hardware AGC) or `"manual"` |
| `value` | f64 | `30.0` | Gain in dB (manual mode only) |
#### `[sdr.squelch]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable software squelch |
| `threshold_db` | f32 | `-65.0` | Open threshold in dBFS (-140..0) |
| `hysteresis_db` | f32 | `3.0` | Close hysteresis in dB (0..40) |
| `tail_ms` | u32 | `180` | Tail hold time in ms (0..10000) |
#### `[[sdr.channels]]`
Defines virtual receiver channels within the wideband IQ stream. The first
channel is the primary channel (controlled by `set_freq`/`set_mode`).
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | string | `""` | Human-readable label |
| `offset_hz` | i64 | `0` | Frequency offset from dial |
| `mode` | string | `"auto"` | Demod mode (`auto`, `LSB`, `USB`, `CW`, `AM`, `FM`, `WFM`, etc.) |
| `audio_bandwidth_hz` | u32 | `3000` | Post-demod audio bandwidth |
| `fir_taps` | usize | `64` | FIR filter tap count |
| `cw_center_hz` | u32 | `700` | CW tone centre frequency |
| `wfm_bandwidth_hz` | u32 | `75000` | WFM pre-demod filter bandwidth |
| `decoders` | string[] | `[]` | Decoder IDs for this channel (`ft8`, `wspr`, `aprs`, `cw`) |
| `stream_opus` | bool | `false` | Stream this channel's audio to clients |
Notes:
- Each decoder ID may appear in at most one channel.
- At most one channel may set `stream_opus = true`.
- Channel IF constraint: `|center_offset_hz + offset_hz| < sample_rate / 2`.
#### `[pskreporter]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable PSKReporter uplink |
| `host` | string | `"report.pskreporter.info"` | Server host |
| `port` | u16 | `4739` | Server port |
| `receiver_locator` | string | — | Maidenhead grid (derived from lat/lon if omitted) |
#### `[aprsfi]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable APRS-IS IGate |
| `host` | string | `"rotate.aprs.net"` | Server host |
| `port` | u16 | `14580` | Server port |
| `passcode` | i32 | `-1` | APRS-IS passcode (-1 = auto from callsign) |
Notes:
- `[general].callsign` must be non-empty when enabled.
- Only APRS packets with valid CRC are forwarded.
- Reconnects with exponential backoff (1 s → 60 s) on TCP errors.
#### `[decode_logs]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable decoder logging |
| `dir` | string | `"$XDG_DATA_HOME/trx-rs/decoders"` | Log directory |
| `aprs_file` | string | `"TRXRS-APRS-%YYYY%-%MM%-%DD%.log"` | APRS log filename |
| `cw_file` | string | `"TRXRS-CW-%YYYY%-%MM%-%DD%.log"` | CW log filename |
| `ft8_file` | string | `"TRXRS-FT8-%YYYY%-%MM%-%DD%.log"` | FT8 log filename |
| `wspr_file` | string | `"TRXRS-WSPR-%YYYY%-%MM%-%DD%.log"` | WSPR log filename |
Files are appended in JSON Lines format. Supported date tokens: `%YYYY%`,
`%MM%`, `%DD%` (UTC).
#### Multi-Rig Configuration
Use `[[rigs]]` arrays instead of the flat `[rig]` section for multi-rig setups:
```toml
[[rigs]]
id = "ft817_0"
name = "HF Transceiver"
[rigs.rig]
model = "ft817"
[rigs.rig.access]
type = "serial"
path = "/dev/ttyUSB0"
baud = 9600
[[rigs]]
id = "sdr_0"
name = "VHF/UHF SDR"
[rigs.rig]
model = "soapysdr"
[rigs.rig.access]
type = "sdr"
args = "driver=rtlsdr"
```
When `[[rigs]]` is present it takes priority over the flat `[rig]` section.
Rigs without an explicit `id` get auto-generated IDs like `ft817_0`, `soapysdr_1`.
### Client Options
#### `[general]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `callsign` | string | `"N0CALL"` | Station callsign |
| `log_level` | string | — | `trace`, `debug`, `info`, `warn`, or `error` |
#### `[remote]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `url` | string | — | Server address (e.g. `localhost:4530`) |
| `poll_interval_ms` | u64 | `750` | State poll interval |
#### `[remote.auth]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `token` | string | — | Auth token (must not be empty if set) |
#### `[frontends.http]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable web UI |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `8080` | Bind port |
#### `[frontends.rigctl]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Hamlib rigctl |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `4532` | Bind port |
#### `[frontends.http_json]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable JSON-over-TCP |
| `listen` | ip | `127.0.0.1` | Bind address |
| `port` | u16 | `0` | Bind port (0 = ephemeral) |
| `auth.tokens` | string[] | `[]` | Allowed auth tokens |
#### `[frontends.audio]`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `true` | Enable audio client |
| `server_port` | u16 | `4531` | Server audio port |
| `bridge.enabled` | bool | `false` | Enable local CPAL audio bridge |
| `bridge.rx_output_device` | string | — | Local playback device |
| `bridge.tx_input_device` | string | — | Local capture device |
| `bridge.rx_gain` | float | `1.0` | RX playback gain |
| `bridge.tx_gain` | float | `1.0` | TX capture gain |
The bridge is intended for WSJT-X integration via virtual audio devices (ALSA
loopback on Linux, BlackHole on macOS).
### CLI Override Summary
**trx-server:**
`--config`, `--print-config`, `--rig`, `--access`, `--callsign`, `--listen`,
`--port`. SDR options are file-only.
**trx-client:**
`--config`, `--print-config`, `--url`, `--token`, `--poll-interval`,
`--frontend`, `--http-listen`, `--http-port`, `--rigctl-listen`,
`--rigctl-port`, `--http-json-listen`, `--http-json-port`, `--callsign`.
---
## Authentication
The HTTP frontend supports optional passphrase-based authentication with two
roles:
- **rx** — read-only access (monitoring, audio, decode streams)
- **control** — full access (frequency, mode, PTT, and all settings)
### Configuration
```toml
[frontends.http.auth]
enabled = false
rx_passphrase = "rx-only-passphrase"
control_passphrase = "full-control-passphrase"
tx_access_control_enabled = true
session_ttl_min = 480
cookie_secure = false # true if served via HTTPS
cookie_same_site = "Lax" # Strict|Lax|None
```
When `enabled = false` (the default), all auth is bypassed and the UI behaves
as before. When enabled, at least one passphrase must be set.
### Behaviour
- On login, the server issues an `HttpOnly` session cookie.
- Sessions are in-memory; a server restart invalidates all sessions.
- Rate limiting is applied per IP to mitigate brute-force attempts.
- When `tx_access_control_enabled = true`, TX/PTT controls are hidden and
rejected for unauthenticated or `rx`-role users.
### Routes
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/auth/login` | POST | Submit `{ "passphrase": "..." }` |
| `/auth/logout` | POST | Clear session |
| `/auth/session` | GET | Check current session/role |
Protected routes require at least `rx` role. Control routes (set frequency,
mode, PTT, etc.) require `control` role.
### Frontend Flow
1. On load, the UI calls `/auth/session`.
2. If unauthenticated, a login screen is shown.
3. On successful login, the normal UI loads.
4. `rx` users see a read-only interface; `control` users get full controls.
5. If a session expires mid-use, streams stop and the login screen returns.
### Transport Security
There is no built-in TLS. For remote access, place trx-rs behind a
TLS-terminating reverse proxy (nginx, Caddy) and set `cookie_secure = true`.
---
## Background Decoding Scheduler
The scheduler automatically retunes the rig to pre-configured bookmarks when no
users are connected to the HTTP frontend. It runs as a background task inside
`trx-frontend-http`, polling every 30 seconds.
### Modes
#### Disabled (default)
Scheduler is inactive. The rig is not touched automatically.
#### Grayline
Retunes around the solar terminator (day/night boundary).
The user provides:
- Station latitude and longitude (decimal degrees)
- Optional transition window width (minutes, default 20)
- Bookmark IDs for four periods:
- **Dawn** — window around sunrise (`sunrise ± window_min/2`)
- **Day** — after dawn until dusk
- **Dusk** — window around sunset (`sunset ± window_min/2`)
- **Night** — after dusk until next dawn
Period precedence (most specific wins): Dawn > Dusk > Day > Night.
If no bookmark is assigned to a period, the rig is not retuned for that period.
Sunrise/sunset is computed inline using the NOAA simplified algorithm. Polar
regions (midnight sun / polar night) fall back to Day/Night accordingly.
#### TimeSpan
Retunes according to a list of user-defined time windows (UTC).
Each entry specifies:
- `start_hhmm` — start of window (e.g. 600 = 06:00 UTC)
- `end_hhmm` — end of window (e.g. 700 = 07:00 UTC)
- `bookmark_id` — bookmark to apply
- `label` — optional human-readable description
Windows that span midnight (`end_hhmm < start_hhmm`) are supported. When
multiple entries overlap, the first match (by list order) wins.
### Storage
Configuration is stored in PickleDB at `~/.config/trx-rs/scheduler.db`.
Keys: `sch:{rig_id}` → JSON `SchedulerConfig`.
### HTTP API
All read endpoints are accessible at the **Rx** role level. Write endpoints
require the **Control** role.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/scheduler/{rig_id}` | Get scheduler config for a rig |
| PUT | `/scheduler/{rig_id}` | Save scheduler config (Control only) |
| DELETE | `/scheduler/{rig_id}` | Reset config to Disabled (Control only) |
| GET | `/scheduler/{rig_id}/status` | Get last-applied bookmark and next event |
### Activation Logic
Every 30 seconds the scheduler task checks:
1. No SSE clients connected
2. Active rig has a non-Disabled scheduler config
3. Current UTC time matches a scheduled window or grayline period
4. If the matching bookmark differs from last applied, send `SetFreq` + `SetMode`
The scheduler does not revert changes when users reconnect.
### Web UI
A dedicated tab with a clock icon provides:
- Rig selector (read-only, shows active rig)
- Mode picker: Disabled / Grayline / TimeSpan
- Grayline section: lat/lon inputs, transition window slider, four bookmark selectors
- TimeSpan section: table of entries with start/end times, bookmark, label
- Status card: last applied bookmark name and timestamp
- Save button (Control role only)
---
## SDR Noise Blanker
The noise blanker suppresses impulse noise (clicks, pops, ignition interference)
on raw IQ samples before any mixing or filtering takes place. It works by
tracking a running RMS level of the signal and replacing any sample whose
magnitude exceeds **threshold x RMS** with the last known clean sample.
### Configuration (server-side)
The noise blanker is configured per rig. In a multi-rig setup each
`[[rigs]]` entry has its own `[rigs.sdr.noise_blanker]` section:
```toml
[[rigs]]
id = "hf"
[rigs.rig]
type = "sdr"
[rigs.sdr.noise_blanker]
enabled = true
threshold = 10.0 # 1 100; lower = more aggressive blanking
```
For the legacy single-rig (flat) config the path is `[sdr.noise_blanker]`:
```toml
[sdr.noise_blanker]
enabled = true
threshold = 10.0
```
| Field | Type | Default | Range | Description |
|-------------|-------|---------|---------|-------------|
| `enabled` | bool | false | — | Turn the noise blanker on or off. |
| `threshold` | float | 10.0 | 1 100 | Multiplier applied to the running RMS. A sample whose magnitude exceeds this multiple is replaced. Lower values blank more aggressively; higher values only catch strong impulses. |
The noise blanker is off by default.
### Choosing a threshold
The threshold controls how aggressively the blanker suppresses impulses.
A value of **N** means: blank any sample whose magnitude exceeds **N times**
the running average signal level.
| Threshold | Behavior | Use case |
|-----------|----------|----------|
| 3 5 | Very aggressive — blanks frequently | Dense impulse noise (motors, power lines, LED drivers nearby) |
| 8 12 | Moderate — catches clear spikes without touching normal signals | Typical HF conditions with occasional ignition or switching noise |
| 15 25 | Conservative — only blanks strong impulses well above the noise floor | Light interference, or when you want minimal artifacts on weak signals |
| 30 100 | Very light — rarely triggers | Faint, infrequent clicks; mostly a safety net |
**Start at 10** (the default) and adjust while listening:
- If impulse noise is still audible, lower the threshold.
- If weak signals sound choppy or distorted, raise it — the blanker may be
mistaking signal peaks for noise.
- On bands with steady atmospheric noise (e.g. 160 m / 80 m), a threshold of
**5 8** usually works well.
- On quieter VHF/UHF bands where the noise floor is low, values of **15 25**
avoid false triggers from strong signals.
### Web UI
When the server reports noise-blanker support, two controls appear in the
**SDR Settings** row of the web interface:
- **Noise Blanker** checkbox — enables or disables the blanker in real time.
- **NB Threshold** number input (1100) with a **Set** button — adjusts the
detection threshold. Press Enter or click Set to apply.
Both controls stay hidden until the server sends filter state containing NB
fields, so they only appear when connected to an SDR backend.
### HTTP API
```
POST /set_sdr_noise_blanker?enabled=true&threshold=10
```
| Parameter | Type | Required | Description |
|-------------|--------|----------|-------------|
| `enabled` | bool | yes | `true` or `false` |
| `threshold` | float | yes | Value between 1 and 100 |
### How it works
The blanker runs on every IQ block (4096 samples) *before* the mixer stage in
the DSP pipeline:
1. For each sample, compute magnitude² (`re² + im²`).
2. Compare against `threshold² × mean_sq` (the exponentially-smoothed running
mean of magnitude²).
3. If the sample exceeds the threshold, replace it with the previous clean
sample.
4. Otherwise, update the running mean with smoothing factor α = 1/128 and store
the sample as the last clean value.
Because the blanker operates on raw IQ before frequency translation, it removes
impulse noise across the entire captured bandwidth regardless of the tuned
channel offset.
-152
View File
@@ -1,152 +0,0 @@
# Weather Satellite Map Overlay Integration
Overlay decoded NOAA APT and Meteor-M LRPT satellite images on the Leaflet
map module, with ground track visualisation and source filtering.
*Created: 2026-03-28*
## Status
| Step | Description | Status |
|------|-------------|--------|
| 1 | Add `sgp4` crate, create `trx-core/src/geo.rs` | Done |
| 2 | Extend `WxsatImage`/`LrptImage` with geo fields | Done |
| 3 | Compute geo-bounds in `finalize_wxsat_pass` / `finalize_lrpt_pass` | Done |
| 4 | Add `wxsat` to map source filter + image overlay rendering | Done |
| 5 | Add ground track polyline + filter toggle UI | Done |
| 6 | Build, test, verify | Done |
## Motivation
The wxsat plugin currently shows a history table with download links but has
no geographic context. Since the Map module already renders APRS, AIS, VDES,
and FTx/WSPR positions, weather satellite images are a natural addition — they
can be projected as semi-transparent overlays on the same Leaflet map.
## Architecture
### Data flow
```mermaid
graph TD
A["Pass decoded (APT / LRPT)"] --> B["finalize_wxsat_pass / finalize_lrpt_pass<br/>(trx-server/audio.rs)"]
B --> C["SGP4 propagation using satellite TLE + pass timestamps"]
C --> D["Compute geo_bounds<br/>[[south, west], [north, east]]"]
D --> E["Compute ground_track<br/>[[lat, lon], ...]"]
E --> F["Attach to WxsatImage / LrptImage"]
F --> G["Broadcast via DecodedMessage"]
G --> H["SSE → browser"]
H --> I["wxsat.js: L.imageOverlay() + L.polyline() on aprsMap"]
```
### Geo-referencing strategy
Weather satellites (NOAA POES, Meteor-M) fly sun-synchronous polar orbits at
~850 km altitude with known TLE parameters. Given:
- **Satellite identity** (from telemetry: NOAA-15/18/19, Meteor-M N2-3/N2-4)
- **Pass start/end timestamps** (`pass_start_ms`, `pass_end_ms`)
- **Receiver station lat/lon** (from `RigState.server_latitude/longitude`)
We can use **SGP4 propagation** (via the `sgp4` crate) to compute the
sub-satellite ground track during the pass, then derive image bounds from the
known swath geometry:
| Parameter | NOAA APT | Meteor LRPT |
|-----------|----------|-------------|
| Altitude | ~850 km | ~825 km |
| Swath width | ~2800 km | ~2800 km |
| Ground speed | ~6.9 km/s | ~6.9 km/s |
| Scan rate | 2 lines/sec (0.5s/line) | variable MCU rate |
| Image width | 909 px/channel | 1568 px |
**Bounds computation:**
1. Propagate satellite position at `pass_start_ms` and `pass_end_ms`
2. Sub-satellite points define the ground track center line
3. Swath half-width (~1400 km) gives east/west extent
4. Image is projected as a simple lat/lon rectangle (acceptable distortion
for the typical ~15° latitude span of a single pass)
**TLE source:** Hardcoded recent TLEs for the 5 active satellites, with an
optional HTTP refresh from CelesTrak. Stale TLEs (weeks old) still give
sub-degree accuracy for image overlay purposes.
### Crate changes
#### `trx-core` (src/trx-core/)
New module `src/trx-core/src/geo.rs`:
- `SatelliteGeo` struct: holds hardcoded TLEs, provides `compute_pass_bounds()`
- `PassGeoBounds { south: f64, west: f64, north: f64, east: f64 }`
- `ground_track(sat, start_ms, end_ms) -> Vec<[f64; 2]>`
- Uses `sgp4` crate for orbital propagation
- Falls back to station-centered approximation when TLE unavailable
`src/trx-core/src/decode.rs` — extend structs:
```rust
pub struct WxsatImage {
// ... existing fields ...
pub geo_bounds: Option<[f64; 4]>, // [south, west, north, east]
pub ground_track: Option<Vec<[f64; 2]>>, // [[lat, lon], ...]
}
// Same for LrptImage
```
#### `trx-server` (src/trx-server/)
`src/trx-server/src/audio.rs`:
- In `finalize_wxsat_pass`: after PNG write, call `SatelliteGeo::compute_pass_bounds()`
using satellite name, pass timestamps, and station lat/lon (threaded through
from config). Attach result to `WxsatImage`.
- Same for `finalize_lrpt_pass`.
#### Frontend (trx-frontend-http/assets/web/)
`plugins/wxsat.js`:
- On `onServerWxsatImage` / `onServerLrptImage`: if `geo_bounds` present,
call `window.addWxsatMapOverlay(msg)`.
- Manage overlay list, allow removal.
`app.js`:
- Add `wxsat: false` to `DEFAULT_MAP_SOURCE_FILTER` (off by default to avoid
visual clutter; users opt-in).
- `window.addWxsatMapOverlay(msg)`: creates `L.imageOverlay(msg.path, bounds)`
with opacity 0.6, adds to `mapMarkers` set with `__trxType = "wxsat"`.
- `window.addWxsatGroundTrack(msg)`: creates `L.polyline(msg.ground_track)`
with dashed style.
- Overlay list in wxsat panel with per-image show/hide toggle.
`index.html`:
- No structural changes needed; the map filter chip system auto-generates
from `DEFAULT_MAP_SOURCE_FILTER`.
`style.css`:
- Styling for wxsat overlay opacity slider (future enhancement).
## Dependencies
| Crate | Version | Purpose |
|-------|---------|---------|
| `sgp4` | 2.4 | Pure Rust SGP4 orbital propagation |
Added to `trx-core/Cargo.toml` (used by `geo.rs`).
## Risk / Limitations
- **Rectangular projection approximation**: The actual scan geometry is curved
(satellite moves along a great circle), but for a single pass spanning
~15-20° of latitude, a lat/lon rectangle is a reasonable first approximation.
More accurate warping could use `L.imageOverlay` with a canvas transform
in a future iteration.
- **TLE staleness**: Hardcoded TLEs drift ~0.1°/week. For overlay purposes
this is acceptable. A periodic CelesTrak fetch would keep them fresh.
- **Image rotation**: Ascending vs descending passes produce different
orientations. The initial implementation uses axis-aligned bounds
(no rotation). A rotated overlay would need `leaflet-imageoverlay-rotated`
or a canvas-based approach — deferred to a follow-up.
- **Image serving**: The `path` field is a filesystem path. On co-located
server/client setups this works directly. Remote setups may need an
image-serving endpoint (out of scope for this change).
-361
View File
@@ -1,361 +0,0 @@
# Frontend Styling & Performance Improvements
*Analysis date: 2026-04-01*
This document captures observations and improvement recommendations for the
trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
application served as embedded static assets (gzip-compressed with ETag
caching) from the Actix-Web server.
## Current asset inventory
| File | Lines | Size |
|------|------:|-----:|
| `style.css` | 5,318 | 144 KB |
| `app.js` | 8,427 | 306 KB |
| `map-core.js` | 3,483 | 127 KB |
| `screenshot.js` | 261 | 10 KB |
| `index.html` | 1,564 | 96 KB |
| `webgl-renderer.js` | 526 | 20 KB |
| `decode-history-worker.js` | 176 | 8 KB |
| `leaflet-ais-tracksymbol.js` | 120 | 8 KB |
| 15 plugin scripts | 7,360 | 304 KB |
| **Total** | **~27,000** | **~1 MB** |
All assets are pre-compressed with `flate2` (gzip, `Compression::best()`) and
served with `ETag` + `If-None-Match` support for conditional requests. The
Actix `Compress` middleware handles dynamic responses.
---
## 1. CSS observations
### 1.1 Monolithic stylesheet (P1)
`style.css` is a single 5,318-line file covering every tab, theme, responsive
breakpoint, map overlay, decoder UI, scheduler, recorder, and settings panel.
Browsers must parse the entire stylesheet before first paint even though most
users only interact with 1-2 tabs at a time.
**Recommendations:**
- Split into logical partitions: `base.css` (variables, reset, layout), `tabs/*.css` (per-tab styles), `themes/*.css`. The server can concatenate and compress at build time.
- At minimum, move the theme colour blocks (lines 3770-5318, ~1,550 lines / 29% of the file) into a separate `themes.css` loaded asynchronously after initial paint, since the default theme is already in `:root`.
- Consider using `@layer` (CSS Cascade Layers) to manage specificity between base, component, and theme styles, eliminating the need for `!important` (currently 21 occurrences).
### 1.2 `backdrop-filter` overuse (P1)
There are 26 `backdrop-filter` declarations (13 pairs with `-webkit-` prefix).
`backdrop-filter: blur()` is one of the most expensive CSS properties -- it
forces the browser to composite, rasterize, and blur everything behind the
element on every frame.
Affected areas: tab bar, controls tray, frequency overlay, modals, connection
banner, bottom nav, neon-disco theme overlay.
**Recommendations:**
- Remove `backdrop-filter` from elements that are always opaque or rarely overlap dynamic content (e.g. bottom tab bar over static background).
- For the spectrum/waterfall overlay controls, use a solid semi-transparent `background` instead of blur -- the visual difference is negligible on a dark spectrogram.
- Where blur is desired (modals), use `will-change: backdrop-filter` and keep blur radius low (4-6px instead of 12-18px). Larger radii are proportionally more expensive.
- Gate expensive blur behind a `@media (prefers-reduced-motion: no-preference)` query or a `[data-effects="full"]` attribute so low-end devices can opt out.
### 1.3 `color-mix()` usage (P2)
184 occurrences of `color-mix(in srgb, ...)` throughout the stylesheet. While
`color-mix` is well-supported in modern browsers, each call is resolved at
computed-value time. Repeated identical mixes (e.g. button hover states
repeated across themes) add unnecessary style recalculation cost.
**Recommendations:**
- Pre-compute frequently used mixes as CSS custom properties in the theme blocks (e.g. `--btn-hover-bg`, `--btn-active-bg`).
- This reduces computed-value work and also makes the palette more explicit and maintainable.
### 1.4 Theme system duplication (P2)
Each of the 10 colour themes repeats ~28 variable declarations for both dark
and light mode (560 variable declarations total). The theme blocks span lines
3770-5318 (29% of the entire stylesheet).
**Recommendations:**
- Move themes to a separate file loaded after first paint (the default `:root` theme is always available).
- Consider generating theme CSS from a data source (JSON/TOML) at build time to reduce manual duplication.
- Use `color-scheme` and `light-dark()` (CSS Color Level 5) to collapse the dark/light pairs where values differ only in lightness.
### 1.5 Transitions on non-essential properties (P3)
25 `transition` declarations, several targeting `background`, `border-color`,
and `box-shadow` simultaneously. Multi-property transitions on buttons and
inputs cause style recalculation on hover/focus for every such element.
**Recommendations:**
- Prefer transitioning only `opacity` and `transform` (GPU-composited).
- For colour changes, use `transition: background-color 100ms` rather than the shorthand `background` which also transitions `background-image` and other sub-properties.
- Add `will-change: transform` only to elements that are actively animating (currently only 2 occurrences, which is good).
### 1.6 Missing `contain` declarations (P2)
Tab content panels, decode history tables, map containers, and spectrum
canvases do not use CSS `contain` or `content-visibility`. When a large decode
history table updates, the browser recalculates layout for the entire page.
**Recommendations:**
- Add `contain: content` to inactive tab panels (`[data-tab]:not(.active)`).
- Add `content-visibility: auto` with `contain-intrinsic-size` to off-screen panels (decode history, map, statistics). This lets the browser skip rendering for hidden content entirely.
- Add `contain: strict` to the spectrum/waterfall canvas containers since their size is fixed and they don't affect sibling layout.
---
## 2. JavaScript observations
### 2.1 Monolithic `app.js` (P1)
The main application script is 11,928 lines (428 KB uncompressed). It is loaded
synchronously in the HTML `<head>` (via embedded asset), blocking first paint
until fully parsed and executed. The 15 plugin scripts add another 7,360 lines.
**Recommendations:**
- Mark the script tag `defer` or move it to end of `<body>` so HTML parsing completes before script execution.
- Split `app.js` into logical modules: `core.js` (SSE, auth, render loop), `spectrum.js`, `map.js`, `decoder.js`, `recorder.js`, `settings.js`. Load non-critical modules lazily when the user navigates to the corresponding tab.
- Use ES modules (`type="module"`) for clean dependency management and tree-shaking potential.
### 2.2 DOM query overhead (P2)
The codebase contains ~359 `querySelector`/`getElementById` calls, many of
which execute on every SSE event (inside `render()`). DOM lookups are not free,
especially `querySelector` with compound selectors.
**Recommendations:**
- Cache DOM references at initialization time (many already are, but the render path still re-queries elements like `document.getElementById("tab-main")`).
- Move repeated lookups (e.g. line 3575 `document.getElementById("tab-main")` inside `es.onmessage`) to module-level constants.
### 2.3 `innerHTML` usage (P2)
33 `innerHTML` assignments in `app.js` and 72 across plugin scripts. Each
`innerHTML` write forces the browser to:
1. Serialize the old DOM subtree for GC
2. Parse the HTML string
3. Build and insert a new DOM subtree
This is both a performance concern (layout thrashing) and a security concern
(XSS if any user-controlled data is interpolated without escaping).
**Recommendations:**
- Replace `innerHTML` with DOM APIs (`createElement`/`appendChild`) or `DocumentFragment` for bulk updates (only 4 `createDocumentFragment` uses currently).
- For large lists (decode history, bookmarks, recorder file lists), use a virtualised list pattern that only renders visible rows.
- Where `innerHTML` is used to clear a container, prefer `replaceChildren()` (clears children without HTML parsing).
### 2.4 SSE render path efficiency (P2)
Every SSE state event triggers `render(update)` which is a ~300-line function
touching dozens of DOM elements. The function does not diff -- it
unconditionally sets properties even when values have not changed.
The string-equality guard (`if (evt.data === lastRendered) return`) is a good
optimisation for identical payloads, but when any field changes (e.g. S-meter
value), the entire render function runs.
**Recommendations:**
- Implement field-level diffing: compare individual fields against previous values and only update DOM elements whose backing data changed.
- Group updates by tab: if the user is on the "Map" tab, skip render work for "Main" tab elements (meters, frequency display, controls).
- Use `scheduleUiFrameJob()` (already exists at line 3685) more aggressively to batch DOM writes into animation frames.
### 2.5 Spectrum/waterfall rendering (P2)
The WebGL renderer (`webgl-renderer.js`) is well-implemented with proper
shader programs and batched draws. However:
- The CSS colour parsing (`parseCssColor`) uses a DOM probe element (appended to
body) and `getComputedStyle` as a fallback, which triggers layout.
- The colour cache is a simple `Map` with no eviction policy.
**Recommendations:**
- Parse theme colours once when the theme changes, not on every frame.
- Invalidate the `cssColorCache` on theme switch events.
### 2.6 Plugin script loading (P3)
All 15 plugin scripts are loaded eagerly in `index.html` regardless of which
decoders are active. Plugins like `ais.js`, `vdes.js`, `sat.js`,
`sat-scheduler.js`, and `hf-aprs.js` are only relevant for specific use cases.
**Recommendations:**
- Load plugin scripts on demand when the corresponding decoder or feature is activated.
- Use dynamic `import()` if migrated to ES modules, or lazy `<script>` injection.
### 2.7 Web Worker utilisation (P3)
Only one Web Worker exists (`decode-history-worker.js`, 176 lines) for CBOR
decode-history parsing. All other heavy work (SSE parsing, DOM updates, spectrum
rendering, map marker management) runs on the main thread.
**Recommendations:**
- Move SSE JSON parsing to a shared worker so the main thread only receives pre-parsed objects.
- Offload spectrum FFT data processing / colour mapping to a worker, posting the resulting `ImageData` to the main thread for canvas rendering.
---
## 3. HTML observations
### 3.1 CDN dependencies (P2)
The page loads one external resource at startup:
- `@fontsource/dseg14-classic/400.css` from `cdn.jsdelivr.net`
~~`leaflet@1.9.4` was previously loaded from `unpkg.com` but is now bundled
as a vendored asset (`/vendor/leaflet.{js,css}` + marker/layer images),
eliminating the CDN dependency.~~
The font uses `rel="preload" as="style"` with an `onload` trick to make it
non-blocking, which is good. However:
- If CDN is unreachable (offline/firewalled deployments common in ham radio),
the font never loads and the frequency display falls back to the system font.
**Recommendations:**
- Self-host the DSEG14 font as an embedded asset (it is small, ~30 KB woff2). This eliminates the CDN dependency entirely and ensures the frequency display always renders correctly.
### 3.2 Inline SVG icons (P3)
Tab bar icons are inline SVGs in the HTML (lines 35-63). Each icon is ~150-250
bytes of markup. This is acceptable for a small number of icons and avoids
extra HTTP requests, but the tab bar HTML is dense and hard to maintain.
**Recommendation:**
- Consider an SVG sprite sheet or moving icons to a small icon font to improve readability without extra requests.
### 3.3 HTML size (P2)
`index.html` is 1,564 lines (96 KB uncompressed). All tab content panels are
present in the initial HTML regardless of which tab is active.
**Recommendations:**
- Use `<template>` elements for tab panels that are not initially visible. Clone and insert them when the tab is first activated. This reduces initial DOM node count and speeds up first paint.
- The server already does template substitution (`{ver}` placeholders). Extend this to strip unused tab content for deployments that don't use certain features.
---
## 4. Responsive design observations
### 4.1 Breakpoints (P3)
Six responsive breakpoints are defined:
- `>1100px`: side bookmark panels
- `<1099px`: hide side bookmarks
- `<900px`: full-width card
- `<760px`: mobile layout (touch targets, stacked controls)
- `<640px`: bottom tab bar, mobile nav
- `<520px`: compact mobile
- `(hover: none) and (pointer: coarse)`: touch-specific
This is a well-structured responsive system. Minor improvements:
- Use `min-width` mobile-first instead of `max-width` desktop-first to reduce CSS specificity conflicts.
- Consider `container queries` for components like the controls tray and decode history table, so they respond to their container size rather than the viewport.
### 4.2 Touch target sizing (P3)
Mobile buttons get `min-height: 2.8rem` at `<760px`. The
`(hover: none) and (pointer: coarse)` media query adds additional touch
accommodations. This meets the 44px minimum recommended by WCAG.
---
## 5. Accessibility observations
### 5.1 `aria-live` regions (P1)
The connection-lost banner and power hint text update dynamically but were
flagged in the Settings-Menu-UX-Analysis as missing `aria-live` on toast
notifications. Ensuring all dynamic status text has `aria-live="polite"` or
`aria-live="assertive"` (for errors) is critical for screen reader users.
### 5.2 Keyboard navigation (P2)
The tab bar uses `<button>` elements (good, natively focusable). However, the
spectrum canvas, jog wheel, and map are mouse/touch-only without keyboard
equivalents. The Settings-Menu-UX-Analysis noted the timeline SVG is not
keyboard-operable.
### 5.3 Colour contrast (P2)
`--text-muted` values (`#91a3bd` on `#0f172a` for dark, `#4a5568` on `#ffffff`
for light) should be verified against WCAG AA (4.5:1 for normal text). The
dark theme muted text calculates to approximately 4.8:1 (passes), but some
theme variants (e.g. Neon Disco) may not meet contrast requirements.
---
## 6. Server-side delivery observations
### 6.1 Asset compression (already good)
Static assets are pre-compressed with `gzip` at `Compression::best()` level
and served with ETag headers. Conditional `304 Not Modified` responses avoid
re-transferring unchanged assets.
### 6.2 Missing `Cache-Control` headers (P2)
While ETags are present, the analysis did not find explicit `Cache-Control`
headers on static assets. Adding `Cache-Control: public, max-age=31536000,
immutable` for versioned assets (with cache-busting query strings) would
eliminate conditional requests entirely for repeat visits.
### 6.3 Consider Brotli compression (P3)
Brotli (`br`) typically achieves 15-25% better compression than gzip for text
assets. For a 428 KB `app.js`, this could save ~60-100 KB of transfer. Actix
supports Brotli via the `Compress` middleware.
---
## 7. Priority summary
```mermaid
quadrantChart
title Impact vs Effort
x-axis Low Effort --> High Effort
y-axis Low Impact --> High Impact
quadrant-1 Do next
quadrant-2 Plan carefully
quadrant-3 Low priority
quadrant-4 Quick wins
"backdrop-filter reduction": [0.25, 0.80]
"Cache-Control headers": [0.15, 0.55]
"CSS contain/content-visibility": [0.30, 0.70]
"Cache DOM refs in render": [0.20, 0.50]
"Theme CSS split": [0.35, 0.45]
"Self-host DSEG14 font": [0.20, 0.40]
"Field-level render diffing": [0.60, 0.75]
"Split app.js into modules": [0.80, 0.70]
"Lazy plugin loading": [0.50, 0.40]
"innerHTML to DOM APIs": [0.65, 0.55]
"Brotli compression": [0.30, 0.25]
"Template-based tab panels": [0.70, 0.60]
```
### Quick wins (low effort, high impact)
1. ~~Reduce `backdrop-filter` usage (13 blur instances)~~ **DONE** -- replaced with solid backgrounds, blur preserved for modals only, `prefers-reduced-motion` gate added
2. ~~Add `contain: content` / `content-visibility: auto` to inactive tabs~~ **DONE** -- containment added for inactive tabs, spectrum/waterfall containers, map, statistics
3. ~~Add `Cache-Control` headers to static assets~~ **DONE** -- upgraded to `public, max-age=31536000, immutable`
4. ~~Cache remaining DOM references in the render path~~ **DONE** -- `tabMainEl` and other hot-path refs cached at module level
### Next phase (moderate effort)
5. ~~Split theme CSS into a separate lazy-loaded file~~ **DONE** -- theme blocks extracted to `/themes.css`, lazy-loaded via `<link rel="preload">`
6. ~~Self-host DSEG14 font~~ **DONE** -- `@font-face` with `font-display: swap` added to `style.css`, CDN preconnect/preload removed from HTML
7. ~~Pre-compute `color-mix` results as CSS variables~~ **DONE** -- common mixes pre-computed as `--btn-hover-bg`, `--btn-active-bg`, etc.
8. ~~Field-level diffing in the SSE render function~~ **DONE** -- `prevRenderData` tracks freq/mode/ptt/meter, active-tab-aware skip logic added
9. ~~Replace `innerHTML` with DOM APIs in hot paths~~ **DONE** -- 15+ `innerHTML = ""` replaced with `replaceChildren()`
### Longer-term
10. ~~Split `app.js` into modules with lazy loading~~ **DONE** -- `map-core.js` (3,480 lines, map/stats/geo) and `screenshot.js` (260 lines) extracted as IIFE modules communicating via `window.trx` namespace; lazy-loaded on tab activation and on-demand respectively; `app.js` reduced from 11,967 to 8,420 lines (30% reduction)
11. ~~Lazy-load plugin scripts and Leaflet on demand~~ **DONE** -- plugin scripts loaded on tab activation, core plugins loaded immediately
12. ~~Use `<template>` elements for deferred tab content~~ **DONE** -- map, statistics, about tabs wrapped in `<template>`, cloned on first activation
13. ~~Migrate to Brotli compression~~ **DONE** -- Brotli added alongside gzip, preferred when `Accept-Encoding: br` present
14. Move SSE parsing and spectrum processing to Web Workers -- **DEFERRED** (requires SharedWorker + MessagePort plumbing, tracked separately)
### Additional improvements implemented
15. ~~Optimize CSS transitions~~ **DONE** -- `background` shorthand → `background-color` for GPU compositing
16. ~~Add `defer` to script tags~~ **DONE** -- all external script tags use `defer`
17. ~~SVG sprite sheet~~ **DONE** -- inline SVGs moved to `<symbol>` defs, referenced via `<use>`
18. ~~aria-live regions~~ **DONE** -- `aria-live` added to power hint, loading indicator
19. ~~Keyboard navigation~~ **DONE** -- `tabindex`/`role`/`aria-label` on spectrum/waterfall canvases
20. ~~Colour contrast~~ **DONE** -- dark theme `--text-muted` improved to `#9bb0ca`
21. ~~WebGL colour cache invalidation~~ **DONE** -- `trxClearCssColorCache()` called on theme switch
22. ~~Container queries~~ **DONE** -- controls tray and decode history table respond to container size
23. ~~Cache-Control immutable~~ **DONE** -- versioned assets use `immutable` directive
-234
View File
@@ -1,234 +0,0 @@
# Scheduler UI Improvement Plan
## Current State
The scheduler UI lives in Settings → Scheduler and provides three operational modes:
- **Grayline** — auto-switches bookmarks based on solar dawn/day/dusk/night
- **Time Span** — UTC time windows with interleaved cycling
- **Satellite Pass** — priority overlay that retunes for satellite passes
Main-view controls include a release button, prev/next step buttons, and a
progress ring showing the active interleave entry and countdown.
Key files:
| File | Purpose |
|------|---------|
| `assets/web/plugins/scheduler.js` | UI logic, rendering, API calls (~1,060 LOC) |
| `assets/web/plugins/sat-scheduler.js` | Satellite config overlay (~310 LOC) |
| `assets/web/index.html` (L11091289) | Scheduler settings HTML |
| `assets/web/style.css` (`.sch-*`) | Scheduler styling |
| `src/scheduler.rs` | Backend task, API handlers (~1,435 LOC) |
---
## P0 — Usability Fixes
### 1. Highlight active entry in time-span table
**Problem:** The entry table under "Entry details" has no indication of which
entry the scheduler is currently operating on. Users must cross-reference the
interleave ring label with the table manually.
**Fix:** In `renderScheduler()`, after receiving status, add/remove an
`sch-active` class on the `<tr>` whose entry id matches
`currentSchedulerStatus.last_entry_id`. Style with a left border accent
(`border-left: 3px solid var(--accent)`).
### 2. Bookmark existence validation on save
**Problem:** If a bookmark is deleted after being assigned to a scheduler entry,
the scheduler fails silently at runtime — it tries to apply a non-existent
bookmark and does nothing.
**Fix:** In `saveScheduler()`, cross-check every `bookmark_id` /
`bookmark_ids[]` against `bookmarkList`. Show a toast error listing the
broken entries and refuse to save until corrected.
### 3. Dirty-state indicator for satellite section
**Problem:** Changes in the satellite section (add/edit/remove satellites,
toggle enable) don't reliably set `schedulerDirty`, so the Save button may
not appear.
**Fix:** Audit all satellite mutation paths in `sat-scheduler.js` and ensure
they call `window.schedulerBridge.markDirty()`.
---
## P1 — Information Density & Clarity
### 4. Show local time alongside UTC
**Problem:** All times are UTC-only. Operators in non-UTC timezones must
mentally convert, especially when editing time-span entries.
**Fix:** Add a `(local)` annotation next to each UTC time display:
- In the entry table, append a dimmed local-time column
- In the timeline SVG, add a secondary tick row with local hours
- Use `Intl.DateTimeFormat` to derive the offset; no config needed
### 5. Expand entry details by default
**Problem:** The entry list is hidden behind a `<details>` collapse. New
users don't discover it, and experienced users click it open every time.
**Fix:** Default the `<details>` element to `open`. Persist the
open/collapsed preference in `localStorage`.
### 6. Richer "Now Playing" status card
**Problem:** The status card shows only `"Last applied: {name} at {time}"`
no frequency, mode, or decoder info.
**Fix:** Extend `SchedulerStatus` (backend) to include `freq_hz`, `mode`,
and `active_decoders[]`. Render them in the status card as
`"14.074 MHz · FT8 · FT8 decoder active"`. Adds immediate visibility
without opening the bookmark manager.
---
## P2 — Interaction Improvements
### 7. Inline entry editing
**Problem:** Editing an entry requires clicking Edit, which opens an overlay
form that obscures the table. Users lose context of adjacent entries.
**Fix:** Replace the overlay with inline editing directly in the table row.
Clicking Edit on a row transforms its cells into input fields (time pickers,
selects) in-place. Save/Cancel buttons appear in the last column. This
keeps sibling entries visible and reduces clicks.
### 8. Drag-to-reorder entries
**Problem:** Entry order matters for interleave cycling, but there is no way
to reorder entries. Users must delete and re-add.
**Fix:** Add drag handles (`⠿`) to each table row. Implement HTML5 drag-and-drop
on the `<tbody>`. On drop, splice the `currentConfig.entries` array and
re-render. Mark dirty.
### 9. Timeline click-to-add
**Problem:** Adding an entry requires clicking "+ Add Entry" and manually
typing start/end times, even though the timeline is a visual 24-hour bar.
**Fix:** Make the timeline SVG interactive. Clicking on an empty region
opens the entry form pre-filled with the clicked hour as start and start+1h
as end. Dragging across a region sets start/end from the drag span. Use
`pointer-events` and `getBoundingClientRect()` to map pixel → minute.
### 10. Improved extra-channels management
**Problem:** Virtual channels use tiny `+`/`` buttons with no indication
of which bookmarks are already added. Removing a channel requires clicking
`` on the right one in a compact list.
**Fix:** Replace with a multi-select chip list: each added channel is a
removable chip (`× 40m FT8`). The `+` button opens the select dropdown.
Already-added bookmarks are disabled in the dropdown to prevent duplicates.
---
## P3 — Feature Enhancements
### 11. Grayline location lookup by grid square
**Problem:** Users must manually enter latitude/longitude. Ham operators
typically know their Maidenhead grid square (e.g. `JO94`) but not their
coordinates to three decimals.
**Fix:** Add a text input for grid square next to the lat/lon fields. On
input, convert the grid square to lat/lon using the standard Maidenhead
algorithm (simple arithmetic, no external API). Populate lat/lon fields
automatically. Also support reverse: when lat/lon changes, show the
derived grid square.
### 12. Expanded satellite preset library
**Problem:** Only two satellite presets (Meteor-M2 3 and M2-4). Adding
NOAA, ISS, or amateur satellites requires looking up NORAD IDs externally.
**Fix:** Expand the preset `<option>` list to include common amateur /
weather satellites:
```
ISS (145.825 MHz APRS) — 25544
SO-50 (436.795 MHz FM) — 27607
```
Low-effort, high-value change — just HTML `<option>` additions plus
corresponding default bookmark templates.
### 13. Scheduler activity log
**Problem:** No way to see what the scheduler did historically — when it
switched, which bookmark it applied, whether any entry was skipped.
**Fix:**
- Backend: Add a ring buffer (last 100 events) to `SchedulerState`.
Each event: `{ utc, action: "applied"|"skipped"|"satellite_aos"|"satellite_los", entry_label, bookmark_name }`.
- API: `GET /scheduler/{rig_id}/log` returns the buffer.
- UI: Add a collapsible "Activity Log" section below the status card.
Render as a reverse-chronological compact list with timestamps.
### 14. Timeline interleave visualization
**Problem:** When multiple entries overlap, the timeline shows overlapping
colored bars but gives no indication of how interleaving splits time between
them.
**Fix:** When interleave is enabled and entries overlap, render alternating
color stripes within the overlap region (e.g., 5-minute tick marks colored
per-entry). Add a legend showing entry label → color mapping.
### 15. Keyboard shortcuts for scheduler control
**Problem:** Release/step controls require mouse clicks on the main view.
During operation, keyboard shortcuts would be faster.
**Fix:** Register global keybindings (configurable in settings):
- `Shift+R` — toggle release to scheduler
- `Shift+N` / `Shift+P` — step to next/previous entry
Guard with `!isInputFocused()` to avoid conflicts with text fields.
---
## Implementation Order
```mermaid
gantt
title Scheduler UI Improvements
dateFormat X
axisFormat %s
section P0
Active entry highlight :1, 2
Bookmark validation :1, 2
Satellite dirty-state fix :1, 2
section P1
Local time display :3, 5
Expand details by default :3, 4
Richer status card :3, 5
section P2
Inline entry editing :6, 9
Drag-to-reorder :6, 8
Timeline click-to-add :6, 9
Extra-channels chips :6, 8
section P3
Grid square lookup :10, 11
Satellite presets :10, 11
Activity log :10, 13
Interleave visualization :10, 13
Keyboard shortcuts :10, 11
```
P0 items are small, targeted fixes (< 1 hour each). P1 items improve daily
usability. P2 items modernize interactions. P3 items add new capabilities.
Each item is independently shippable.
-837
View File
@@ -1,837 +0,0 @@
# WEFAX / Radiofax Decoder Implementation Plan
> **Crate**: `trx-wefax` &mdash; `src/decoders/trx-wefax/`
> **Status**: Implemented (Phases 13b) &mdash; 2026-04-02
## 1. Overview
WEFAX (Weather Facsimile, ITU-T T.4 / WMO) is an analog image transmission
mode used by meteorological agencies worldwide (NOAA, DWD, JMH, etc.) on HF
and satellite downlinks. The decoder converts FM-modulated audio tones into
greyscale (or colour-composited) image lines.
### Goals
- Pure Rust, zero C FFI dependencies (matching project conventions).
- Multi-speed support: **60, 90, 120, 240 LPM** (lines per minute).
- Multi-IOC support: **288 and 576** (Index of Cooperation &mdash; defines
line pixel width).
- Automatic start/stop detection via APT tones.
- Phase-aligned line assembly from phasing signal.
- Incremental image output (line-by-line progress + final PNG).
- Follow existing decoder patterns (`process_block` / `decode_if_ready`).
## 2. WEFAX Signal Structure
```
Carrier (1900 Hz center, ±400 Hz deviation)
Black = 1500 Hz
White = 2300 Hz
(linear mapping between frequency and luminance)
Transmission sequence:
┌─────────────┐
│ Start tone │ 300 Hz (5s) or 675 Hz (3s) — selects IOC 576 / 288
├─────────────┤
│ Phasing │ >95% white line + narrow black pulse — phase alignment
│ (30 lines) │
├─────────────┤
│ Image lines │ N lines at configured LPM
├─────────────┤
│ Stop tone │ 450 Hz (5s) — signals end of transmission
└─────────────┘
```
### Key parameters
| Parameter | IOC 576 | IOC 288 |
|-----------|---------|---------|
| Pixels per line | 1809 | 904 |
| Line duration (120 LPM) | 500 ms | 500 ms |
| Line duration (60 LPM) | 1000 ms | 1000 ms |
| Pixel clock | ~3618 px/s (120 LPM) | ~1808 px/s (120 LPM) |
Pixel count per line = `IOC × π` (rounded: 576×π ≈ 1809, 288×π ≈ 904).
## 3. Architecture
```mermaid
graph TD
PCM["PCM audio (f32, 48 kHz)"] --> RS["Resampler (to internal rate)"]
RS --> FM["FM Discriminator"]
FM --> LPF["Low-pass filter (anti-alias)"]
LPF --> TD["Tone Detector (APT start/stop)"]
LPF --> PA["Phase Aligner"]
PA --> LS["Line Slicer"]
LS --> IMG["Image Assembler"]
IMG --> OUT["WefaxMessage (line / image)"]
TD --> SM["State Machine"]
SM -->|controls| PA
SM -->|controls| LS
```
### Internal sample rate
Resample input to **11,025 Hz** (sufficient for 2300 Hz max tone with
comfortable margin; matches common WEFAX decoder practice and keeps DSP
cost low).
## 4. Module Layout
```
src/decoders/trx-wefax/
Cargo.toml
src/
lib.rs # Public API: WefaxDecoder, WefaxConfig, WefaxEvent
decoder.rs # Top-level decoder state machine + process_block/decode_if_ready
demod.rs # FM discriminator (instantaneous frequency from analytic signal)
tone_detect.rs # Goertzel-based APT tone detector (300/450/675 Hz)
phase.rs # Phasing signal detector and line-start alignment
line_slicer.rs # Pixel clock recovery, line buffer assembly
resampler.rs # Polyphase rational resampler (48k → 11025)
image.rs # Image buffer, PNG encoding, optional colour compositing
config.rs # WefaxConfig: speed, IOC, auto-detect, output path
```
## 5. Core Types
### 5.1 Configuration
```rust
pub struct WefaxConfig {
/// Lines per minute: 60, 90, 120, 240. `None` = auto-detect from APT.
pub lpm: Option<u16>,
/// Index of Cooperation: 288 or 576. `None` = auto-detect from start tone.
pub ioc: Option<u16>,
/// Centre frequency of the FM subcarrier (default 1900 Hz).
pub center_freq_hz: f32,
/// Deviation (default ±400 Hz, so black=1500, white=2300).
pub deviation_hz: f32,
/// Directory for saving decoded images.
pub output_dir: Option<String>,
/// Whether to emit line-by-line progress events.
pub emit_progress: bool,
}
```
### 5.2 Decoder state machine
```rust
pub enum WefaxState {
/// Listening for APT start tone.
Idle,
/// Start tone detected; waiting for phasing signal.
StartDetected { ioc: u16, tone_start_sample: u64 },
/// Receiving phasing lines; aligning line-start phase.
Phasing { ioc: u16, lpm: u16, phase_offset: Option<usize> },
/// Actively decoding image lines.
Receiving { ioc: u16, lpm: u16, line_number: u32 },
/// Stop tone detected; finalising image.
Stopping,
}
```
### 5.3 Output messages (for `trx-core::DecodedMessage`)
```rust
/// A complete or in-progress WEFAX image.
pub struct WefaxMessage {
pub rig_id: Option<String>,
pub ts_ms: Option<i64>,
/// Number of image lines decoded so far.
pub line_count: u32,
/// Detected or configured LPM.
pub lpm: u16,
/// Detected or configured IOC.
pub ioc: u16,
/// Pixels per line (IOC × π, rounded).
pub pixels_per_line: u16,
/// Filesystem path to saved PNG (set on completion).
pub path: Option<String>,
/// True when image is complete (stop tone received).
pub complete: bool,
}
/// Progress update emitted every N lines during active reception.
pub struct WefaxProgress {
pub rig_id: Option<String>,
pub line_count: u32,
pub lpm: u16,
pub ioc: u16,
}
```
## 6. DSP Pipeline Detail
### 6.1 Resampling
Rational polyphase resampler: 48000 → 11025 Hz (ratio 441/1920, simplified
from 11025/48000). Follow `docs/Optimization-Guidelines.md` polyphase
resampler guidance. Same pattern as FT8 decoder's 48k→12k resampler.
### 6.2 FM Discriminator
Compute instantaneous frequency from the analytic signal:
1. **Hilbert transform** (FIR, 65-tap) to produce analytic signal `z[n]`.
2. **Instantaneous frequency**: `f[n] = arg(z[n] · conj(z[n-1])) / (2π·Ts)`
3. Map frequency to luminance: `pixel = clamp((f - 1500) / 800, 0, 1)`.
The Hilbert + frequency discriminator approach avoids PLL complexity and works
well for the relatively low data rate of WEFAX.
### 6.3 APT Tone Detection
Use **Goertzel filters** at three frequencies (matching `trx-cw` pattern):
| Tone | Frequency | Meaning |
|------|-----------|---------|
| Start (IOC 576) | 300 Hz | Begin reception, IOC=576 |
| Start (IOC 288) | 675 Hz | Begin reception, IOC=288 |
| Stop | 450 Hz | End of transmission |
Detection window: ~200 ms (2205 samples at 11025 Hz). Require sustained
detection for ≥1.5 s to confirm (debounce against noise). Energy ratio
vs broadband noise for reliability.
### 6.4 Phasing Signal Detection
During phasing, each line is >95% white (2300 Hz) with a narrow black pulse
(~5% of line width) at the line-start position.
1. After start tone, begin accumulating demodulated samples.
2. Slice into line-duration windows (e.g., 500 ms for 120 LPM).
3. Cross-correlate against expected phasing template (short black pulse).
4. Average pulse position over 10+ phasing lines → line-start phase offset.
5. Transition to `Receiving` once phase is stable (variance < 2 samples).
### 6.5 Line Slicing and Pixel Clock
Once phased:
1. Accumulate demodulated (frequency → luminance) samples.
2. At each line boundary (determined by LPM and phase offset), extract
one line of `pixels_per_line` values via linear interpolation from
the sample buffer.
3. Push completed line into the image assembler.
4. Emit `WefaxProgress` every 50 lines (configurable).
### 6.6 Image Assembly
- Maintain a `Vec<Vec<u8>>` of greyscale lines (0255).
- On stop tone or manual stop: encode to 8-bit greyscale PNG.
- Save to `output_dir` with filename pattern:
`WEFAX-{YYYY}-{MM}-{DD}T{HH}{mm}{ss}-IOC{ioc}-{lpm}lpm.png`
- Return `WefaxMessage` with `complete: true` and `path` set.
## 7. Integration with trx-rs
### 7.1 Workspace registration
Add to root `Cargo.toml` workspace members:
```toml
"src/decoders/trx-wefax"
```
### 7.2 `trx-core` changes
Add variants to `DecodedMessage`:
```rust
#[serde(rename = "wefax")]
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
```
Update `set_rig_id()` / `rig_id()` match arms.
### 7.3 `trx-server` integration
Add `run_wefax_decoder()` in `audio.rs` following the existing pattern:
```rust
pub async fn run_wefax_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
logs: Option<Arc<DecoderLoggers>>,
histories: Arc<DecoderHistories>,
)
```
Spawn in `main.rs` alongside other decoders, gated by mode (USB/LSB on
HF WEFAX frequencies).
### 7.4 History and logging
- Add `wefax: Arc<Mutex<VecDeque<WefaxMessage>>>` to `DecoderHistories`.
- Add optional `wefax` logger to `DecoderLoggers` (JSON Lines).
### 7.5 Frontend exposure
The web frontend follows the existing decoder plugin pattern used by WSPR,
FT8, AIS, etc. WEFAX is unique among decoders because it produces **images**
rather than text rows, so the UI uses a `<canvas>` for live line-by-line
rendering instead of the tabular layout used by other decoders.
#### 7.5.1 Rust backend wiring (`trx-frontend-http`)
**`src/status.rs`** &mdash; embed the plugin script:
```rust
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
```
**`src/api/assets.rs`** &mdash; define the gzip-cached route:
```rust
define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js");
#[get("/wefax.js")]
pub(crate) async fn wefax_js(req: HttpRequest) -> impl Responder {
let c = gz_wefax_js();
static_asset_response(&req, "application/javascript; charset=utf-8", c)
}
```
**`src/api/decoder.rs`** &mdash; add endpoints:
```rust
#[post("/toggle_wefax_decode")]
pub async fn toggle_wefax_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().decoders.wefax_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetWefaxDecodeEnabled(!enabled),
query.into_inner().remote,
)
.await
}
#[post("/clear_wefax_decode")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_wefax_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetWefaxDecoder,
query.into_inner().remote,
)
.await
}
```
**`src/api/mod.rs`** &mdash; register in `configure()`:
```rust
.service(decoder::toggle_wefax_decode)
.service(decoder::clear_wefax_decode)
.service(assets::wefax_js)
```
**Decode history** &mdash; add `"wefax"` key to the CBOR payload returned
by `GET /decode/history`, containing `Vec<WefaxMessage>` (completed images
only; in-progress images are streamed via SSE).
**SSE `/decode` stream** &mdash; broadcast two event shapes:
```json
{"wefax_progress": {"line_count": 142, "lpm": 120, "ioc": 576, "pixels_per_line": 1809,
"line_data": "<base64-encoded u8 greyscale row>"}}
{"wefax": {"ts_ms": 1712000000000, "line_count": 800, "lpm": 120, "ioc": 576,
"pixels_per_line": 1809, "complete": true,
"path": "/images/WEFAX-2026-04-02T1430-IOC576-120lpm.png"}}
```
`wefax_progress` events carry a base64 `line_data` field (one image row of
greyscale bytes) so the browser can paint each line as it arrives without
needing a separate WebSocket channel.
**Decoder registry** &mdash; add entry to `DECODER_REGISTRY` in
`trx-protocol`:
```rust
DecoderRegistryEntry {
id: "wefax",
label: "WEFAX",
activation: "toggle", // enable/disable button
active_modes: &["usb", "lsb", "am"],
background_decode: false,
bookmark_selectable: true,
}
```
#### 7.5.2 HTML additions (`index.html`)
**Sub-tab button** (inside `.sub-tab-bar`, after the existing decoder
buttons):
```html
<button class="sub-tab" data-subtab="wefax" id="subtab-wefax">WEFAX</button>
```
**Sub-tab panel** (alongside other `sub-tab-panel` divs):
```html
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
<button id="wefax-decode-toggle-btn" type="button">Enable WEFAX</button>
<button id="wefax-clear-btn" type="button"
style="margin-left:0.5rem; font-size:0.8rem;">Clear</button>
<small id="wefax-status" style="color:var(--text-muted);">Idle</small>
</div>
<!-- Live image canvas — painted line-by-line during reception -->
<div id="wefax-live-container" style="display:none; margin:0.5rem 0;">
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
<strong>Receiving</strong>
<small id="wefax-live-info" style="color:var(--text-muted);"></small>
</div>
<canvas id="wefax-live-canvas" width="1809" height="800"
style="width:100%; image-rendering:pixelated; background:#000;"></canvas>
</div>
<!-- Gallery of completed images -->
<div id="wefax-gallery" style="display:flex; flex-wrap:wrap; gap:0.5rem;"></div>
</div>
```
**Overview section** (inside the digital-modes overview panel):
```html
<div class="plugin-item" data-decoder="wefax">
<strong>WEFAX Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Weather Facsimile &mdash; HF/satellite image reception (60/90/120/240 LPM)
</div>
</div>
```
**About section** (in the About tab decoder list):
```html
<tr id="about-dec-wefax"><td>WEFAX</td><td>Weather Facsimile decoder</td></tr>
```
#### 7.5.3 Plugin script registration
**`index.html` plugin map** &mdash; add `'/wefax.js'` to the
`'digital-modes'` array in `pluginScripts`:
```javascript
var pluginScripts = {
'digital-modes': ['/ft8.js', ..., '/wefax.js'],
// ...
};
```
#### 7.5.4 SSE dispatch in `app.js`
Add WEFAX to the decode event dispatcher (inside `decodeSource.onmessage`):
```javascript
if (msg.wefax_progress && window.onServerWefaxProgress) {
window.onServerWefaxProgress(msg.wefax_progress);
}
if (msg.wefax && window.onServerWefax) {
window.onServerWefax(msg.wefax);
}
```
Add `"wefax"` to the decode history restore loop:
```javascript
// In loadDecodeHistoryOnMainThread / worker dispatch:
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs",
"cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
```
Add WEFAX to `restoreDecodeHistoryGroup()`:
```javascript
case "wefax":
if (window.restoreWefaxHistory) window.restoreWefaxHistory(messages);
break;
```
#### 7.5.5 Plugin file (`assets/web/plugins/wefax.js`)
Full plugin structure following the project's vanilla-JS decoder plugin
pattern:
```javascript
// ---------------------------------------------------------------------------
// wefax.js — WEFAX decoder plugin for trx-frontend-http
// ---------------------------------------------------------------------------
// --- DOM refs ---
const wefaxStatus = document.getElementById('wefax-status');
const wefaxLiveContainer= document.getElementById('wefax-live-container');
const wefaxLiveInfo = document.getElementById('wefax-live-info');
const wefaxLiveCanvas = document.getElementById('wefax-live-canvas');
const wefaxGallery = document.getElementById('wefax-gallery');
const wefaxToggleBtn = document.getElementById('wefax-decode-toggle-btn');
const wefaxClearBtn = document.getElementById('wefax-clear-btn');
// --- State ---
let wefaxImageHistory = []; // completed WefaxMessage objects
let wefaxLiveCtx = null; // canvas 2D context
let wefaxLiveLineCount = 0; // lines painted so far
let wefaxLivePixelsPerLine = 1809;
// --- Helpers ---
function currentWefaxHistoryRetentionMs() {
return window.getDecodeHistoryRetentionMs?.() || 24 * 60 * 60 * 1000;
}
function pruneWefaxHistory() {
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(m => (m._tsMs || 0) > cutoff);
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
// --- Live canvas rendering ---
/** Reset canvas for a new image reception. */
function resetLiveCanvas(pixelsPerLine) {
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
wefaxLiveCanvas.width = pixelsPerLine;
wefaxLiveCanvas.height = 800; // grows if needed
wefaxLiveCtx = wefaxLiveCanvas.getContext('2d');
wefaxLiveCtx.fillStyle = '#000';
wefaxLiveCtx.fillRect(0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveContainer.style.display = '';
}
/** Append one greyscale line (Uint8Array) to the live canvas. */
function paintLine(lineBytes) {
if (!wefaxLiveCtx) return;
const y = wefaxLiveLineCount;
// Grow canvas vertically if needed (double height strategy).
if (y >= wefaxLiveCanvas.height) {
const old = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveCanvas.height *= 2;
wefaxLiveCtx.putImageData(old, 0, 0);
}
const w = wefaxLivePixelsPerLine;
const imgData = wefaxLiveCtx.createImageData(w, 1);
const d = imgData.data;
for (let x = 0; x < w; x++) {
const v = x < lineBytes.length ? lineBytes[x] : 0;
const i = x * 4;
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
// --- Gallery rendering ---
function renderGalleryThumbnail(msg) {
const card = document.createElement('div');
card.className = 'wefax-card';
card.style.cssText =
'border:1px solid var(--border-color); border-radius:4px; ' +
'padding:0.4rem; max-width:280px; cursor:pointer;';
const ts = msg._tsMs
? new Date(msg._tsMs).toLocaleString()
: '—';
const info = `${msg.ioc} IOC · ${msg.lpm} LPM · ${msg.line_count} lines`;
// If a server path is available, show a thumbnail linking to it.
if (msg.path) {
card.innerHTML =
`<img src="/images/${escapeHtml(msg.path.split('/').pop())}"
alt="WEFAX" loading="lazy"
style="width:100%; image-rendering:pixelated;" />` +
`<div style="font-size:0.8rem; margin-top:0.2rem;">${escapeHtml(ts)}</div>` +
`<div style="font-size:0.75rem; color:var(--text-muted);">${info}</div>`;
} else {
card.innerHTML =
`<div style="font-size:0.8rem;">${escapeHtml(ts)}</div>` +
`<div style="font-size:0.75rem; color:var(--text-muted);">${info}</div>`;
}
return card;
}
function renderWefaxGallery() {
pruneWefaxHistory();
const frag = document.createDocumentFragment();
for (const msg of wefaxImageHistory) {
frag.appendChild(renderGalleryThumbnail(msg));
}
wefaxGallery.innerHTML = '';
wefaxGallery.appendChild(frag);
}
function scheduleWefaxGalleryRender() {
if (window.trxScheduleUiFrameJob) {
window.trxScheduleUiFrameJob('wefax-gallery', renderWefaxGallery);
} else {
requestAnimationFrame(renderWefaxGallery);
}
}
// --- SSE event handlers (public API) ---
/** Called for each wefax_progress SSE event (one image line). */
window.onServerWefaxProgress = function (msg) {
// First progress event of a new image → reset canvas.
if (msg.line_count <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
// Decode base64 line_data → Uint8Array → paint.
if (msg.line_data) {
const binary = atob(msg.line_data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
// Update status text.
if (wefaxLiveInfo) {
wefaxLiveInfo.textContent =
`Line ${msg.line_count} · ${msg.ioc} IOC · ${msg.lpm} LPM`;
}
if (wefaxStatus) {
wefaxStatus.textContent = `Receiving — line ${msg.line_count}`;
wefaxStatus.style.color = 'var(--text-accent)';
}
};
/** Called when a complete WEFAX image is received. */
window.onServerWefax = function (msg) {
msg._tsMs = msg.ts_ms || Date.now();
wefaxImageHistory.unshift(msg);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
// Finalise live canvas — trim height to actual line count.
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
const trimmed = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveLineCount);
wefaxLiveCanvas.height = wefaxLiveLineCount;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
}
if (wefaxStatus) {
wefaxStatus.textContent = `Complete — ${msg.line_count} lines`;
wefaxStatus.style.color = '';
}
};
/** Batch restore from decode history (page load). */
window.restoreWefaxHistory = function (messages) {
if (!messages || !messages.length) return;
for (const m of messages) {
m._tsMs = m.ts_ms || Date.now();
}
wefaxImageHistory = messages.concat(wefaxImageHistory);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Called by history retention pruning cycle. */
window.pruneWefaxHistoryView = function () {
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Full reset (rig change, clear). */
window.resetWefaxHistoryView = function () {
wefaxImageHistory = [];
wefaxGallery.innerHTML = '';
wefaxLiveContainer.style.display = 'none';
wefaxLiveCtx = null;
wefaxLiveLineCount = 0;
if (wefaxStatus) {
wefaxStatus.textContent = 'Idle';
wefaxStatus.style.color = '';
}
};
// --- Button handlers ---
if (wefaxClearBtn) {
wefaxClearBtn.addEventListener('click', function () {
fetch('/clear_wefax_decode', { method: 'POST' });
window.resetWefaxHistoryView();
});
}
```
#### 7.5.6 Data flow summary
```mermaid
sequenceDiagram
participant Server as trx-server (wefax decoder)
participant SSE as SSE /decode
participant Plugin as wefax.js
participant Canvas as <canvas>
participant Gallery as Gallery div
Server->>SSE: wefax_progress (line_data base64)
SSE->>Plugin: onServerWefaxProgress()
Plugin->>Canvas: paintLine() — one greyscale row
Note over Server: ...repeats per line...
Server->>SSE: wefax (complete=true, path)
SSE->>Plugin: onServerWefax()
Plugin->>Canvas: trim canvas to final height
Plugin->>Gallery: renderGalleryThumbnail()
```
#### 7.5.7 Image serving
Completed PNG files saved by the decoder need an HTTP route for browser
access. Add a static-file route in `assets.rs`:
```rust
#[get("/images/{filename}")]
pub(crate) async fn wefax_image(
req: HttpRequest,
path: web::Path<String>,
) -> impl Responder {
// Serve from WefaxConfig::output_dir, validate filename (no path traversal).
// Content-Type: image/png, Cache-Control: public, max-age=86400.
}
```
Register in `api/mod.rs`:
```rust
.service(assets::wefax_image)
```
#### 7.5.8 Decode history worker update
Add `"wefax"` to `HISTORY_GROUP_KEYS` in `decode-history-worker.js`:
```javascript
const HISTORY_GROUP_KEYS = [
"ais", "vdes", "aprs", "hf_aprs", "cw",
"ft8", "ft4", "ft2", "wspr", "wefax"
];
```
## 8. Implementation Phases
### Phase 1: Core DSP (MVP) ✅
1.**Resampler** &mdash; 48k→11025 polyphase resampler with tests.
2.**FM discriminator** &mdash; Hilbert FIR + instantaneous freq, verify
against synthetic 15002300 Hz sweeps.
3.**Tone detector** &mdash; Goertzel at 300/450/675 Hz with debounce.
4.**Line slicer** &mdash; Fixed-config (manual LPM+IOC) line extraction.
5.**Image buffer + PNG** &mdash; Greyscale line accumulation, `png`
crate for encoding.
Deliverable: decode a known WEFAX WAV recording at a single speed/IOC.
### Phase 2: Automatic Detection ✅
6.**State machine** &mdash; Full `Idle→StartDetected→Phasing→Receiving→Stopping`
transitions driven by tone detector.
7.**Phase alignment** &mdash; Cross-correlation phasing detector.
8.**Auto IOC/LPM** &mdash; IOC from start tone frequency; LPM from phasing
line duration measurement.
Deliverable: fully automatic reception of a single image without manual config.
### Phase 3: Server Integration ✅
9.**`trx-core` message types** &mdash; `WefaxMessage`, `WefaxProgress` in
`DecodedMessage`.
10.**`trx-server` task** &mdash; `run_wefax_decoder()`, history, logging.
11.**Protocol registry** &mdash; `DECODER_REGISTRY` entry for `"wefax"`.
Deliverable: backend wefax decoding with SSE event broadcast.
### Phase 3b: Frontend Wiring ✅
12.**Rust asset pipeline** &mdash; `status.rs` embed, `assets.rs` gzip
cache + route, `decoder.rs` toggle/clear endpoints, `api/mod.rs`
registration (§7.5.1).
13.**HTML scaffold** &mdash; sub-tab button, sub-tab panel with canvas +
gallery, overview entry, about row (§7.5.2).
14.**Plugin loading** &mdash; add `/wefax.js` to `pluginScripts`
`'digital-modes'` array (§7.5.3).
15.**SSE dispatch** &mdash; `wefax` / `wefax_progress` handlers in
`app.js` decode event dispatcher (§7.5.4).
16.**`wefax.js` plugin** &mdash; live canvas rendering, gallery
thumbnails, history restore, toggle/clear wiring (§7.5.5).
17. **Image serving** &mdash; `/images/{filename}` static route for
completed PNGs (§7.5.7). *(deferred: images served from output_dir)*
18.**History worker** &mdash; add `"wefax"` to `HISTORY_GROUP_KEYS`
(§7.5.8).
Deliverable: end-to-end live WEFAX decoding with in-browser image preview.
### Phase 4: Polish
19. **Multi-speed runtime switching** &mdash; handle back-to-back
transmissions at different LPM within one session.
20. **Slant correction** &mdash; fine-tune sample clock drift compensation
using phasing pulse tracking.
21. **Colour compositing** &mdash; optional IR + visible overlay for
satellite WEFAX (future).
22. **Test suite** &mdash; synthetic signal generation, round-trip tests,
edge cases (partial images, noise, frequency offset).
## 9. Dependencies
```toml
[dependencies]
trx-core = { path = "../../trx-core" }
rustfft = "6" # Hilbert transform FIR via FFT overlap-save (optional)
png = "0.17" # PNG encoding (lightweight, no image full dep)
```
No additional heavy dependencies required. The DSP components (Goertzel,
polyphase resampler, Hilbert FIR) are small enough to implement inline,
consistent with the pure-Rust approach of `trx-rds`, `trx-cw`, and
`trx-ftx`.
## 10. Testing Strategy
| Test | Method |
|------|--------|
| FM discriminator accuracy | Synthesise known-frequency tones, verify ±1 Hz |
| Tone detection | Inject 300/450/675 Hz bursts, verify timing |
| Phase alignment | Synthetic phasing signal with known pulse position |
| Line pixel accuracy | Known gradient pattern → verify pixel values |
| Full decode round-trip | Reference WEFAX WAV → compare output PNG against known-good |
| Multi-speed switching | Sequential 120 LPM + 60 LPM images in one stream |
| Noise resilience | Add white noise at various SNR, verify graceful degradation |
## 11. References
- ITU-R BT.601 (facsimile signal characteristics)
- WMO Manual on the GTS, Attachment II-13 (HF radiofax schedule/format)
- NOAA Radiofax Charts: frequency schedules and IOC/LPM per product
- Existing open-source implementations: `fldigi` WEFAX module, `multimon-ng`
+18
View File
@@ -0,0 +1,18 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-plugin-example"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
trx-backend = { path = "../../src/trx-server/trx-backend" }
trx-core = { path = "../../src/trx-core" }
trx-frontend = { path = "../../src/trx-client/trx-frontend" }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
+19
View File
@@ -0,0 +1,19 @@
# trx-plugin-example
This is a minimal shared-library plugin that registers a backend and frontend.
The backend is a stub that returns an error; the frontend is a no-op spawner.
Build:
```bash
cargo build -p trx-plugin-example --release
```
Install (example):
```bash
mkdir -p plugins
cp target/release/libtrx_plugin_example.* plugins/
```
Run `trx-server` or `trx-client` with `TRX_PLUGIN_DIRS=./plugins` to discover the plugin.
+50
View File
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use std::net::SocketAddr;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::info;
use trx_backend::{RegistrationContext, RigAccess};
use trx_core::{DynResult, RigRequest, RigState};
use trx_frontend::{FrontendRuntimeContext, FrontendSpawner, FrontendRegistrationContext};
const BACKEND_NAME: &str = "example";
const FRONTEND_NAME: &str = "example-frontend";
/// Entry point called by trx-server when the plugin is loaded.
#[no_mangle]
pub extern "C" fn trx_register_backend(context: *mut std::ffi::c_void) {
let context = unsafe { &mut *(context as *mut RegistrationContext) };
context.register_backend(BACKEND_NAME, example_backend_factory);
}
/// Entry point called by trx-client when the plugin is loaded.
#[no_mangle]
pub extern "C" fn trx_register_frontend(context: *mut std::ffi::c_void) {
let context = unsafe { &mut *(context as *mut FrontendRegistrationContext) };
context.register_frontend(FRONTEND_NAME, ExampleFrontend::spawn_frontend);
}
fn example_backend_factory(_access: RigAccess) -> DynResult<Box<dyn trx_core::rig::RigCat>> {
Err("example plugin backend not implemented".into())
}
struct ExampleFrontend;
impl FrontendSpawner for ExampleFrontend {
fn spawn_frontend(
_state_rx: watch::Receiver<RigState>,
_rig_tx: mpsc::Sender<RigRequest>,
_callsign: Option<String>,
listen_addr: SocketAddr,
_context: std::sync::Arc<FrontendRuntimeContext>,
) -> JoinHandle<()> {
tokio::spawn(async move {
info!("example frontend loaded at {} (no-op)", listen_addr);
})
}
}
+33
View File
@@ -0,0 +1,33 @@
BasedOnStyle: WebKit
# Cpp11BracedListStyle: false
# ColumnLimit: 120
IndentCaseLabels: false
IndentExternBlock: false
IndentWidth: 4
TabWidth: 8
UseTab: Never
PointerAlignment: Left
SortIncludes: false
AlignConsecutiveMacros: true
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: false
AlignTrailingComments: true
BreakConstructorInitializers: BeforeColon
ConstructorInitializerAllOnOneLineOrOnePerLine: true
ConstructorInitializerIndentWidth: 0
BreakBeforeBraces: Custom
BreakBeforeBinaryOperators: All
BraceWrapping:
AfterControlStatement: true
AfterClass: true
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterStruct: true
AfterUnion: true
AfterExternBlock: true
BeforeElse: true
BeforeCatch: true
+9
View File
@@ -0,0 +1,9 @@
gen_ft8
decode_ft8
test_ft8
libft8.a
wsjtx2/
.build/
.DS_Store
.vscode/
__pycache__/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Kārlis Goba
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+58
View File
@@ -0,0 +1,58 @@
BUILD_DIR = .build
FT8_SRC = $(wildcard ft8/*.c)
FT8_OBJ = $(patsubst %.c,$(BUILD_DIR)/%.o,$(FT8_SRC))
COMMON_SRC = $(wildcard common/*.c)
COMMON_OBJ = $(patsubst %.c,$(BUILD_DIR)/%.o,$(COMMON_SRC))
FFT_SRC = $(wildcard fft/*.c)
FFT_OBJ = $(patsubst %.c,$(BUILD_DIR)/%.o,$(FFT_SRC))
TARGETS = libft8.a gen_ft8 decode_ft8 test_ft8
ifdef FT8_DEBUG
CFLAGS = -fsanitize=address -ggdb3 -DHAVE_STPCPY -I. -DFTX_DEBUG_PRINT
LDFLAGS = -fsanitize=address -lm
else
CFLAGS = -O3 -DHAVE_STPCPY -I.
LDFLAGS = -lm
endif
# Optionally, use Portaudio for live audio input
# Portaudio is a C++ library, so then you need to set CC=clang++ or CC=g++
ifdef PORTAUDIO_PREFIX
CFLAGS += -DUSE_PORTAUDIO -I$(PORTAUDIO_PREFIX)/include
LDFLAGS += -lportaudio -L$(PORTAUDIO_PREFIX)/lib
endif
.PHONY: all clean run_tests install
all: $(TARGETS)
clean:
rm -rf $(BUILD_DIR) $(TARGETS)
run_tests: test_ft8
@./test_ft8
install: libft8.a
install libft8.a /usr/lib/libft8.a
gen_ft8: $(BUILD_DIR)/demo/gen_ft8.o libft8.a
$(CC) $(CFLAGS) -o $@ .build/demo/gen_ft8.o -lft8 -L. -lm
decode_ft8: $(BUILD_DIR)/demo/decode_ft8.o libft8.a $(FFT_OBJ)
$(CC) $(CFLAGS) -o $@ $(BUILD_DIR)/demo/decode_ft8.o $(FFT_OBJ) -lft8 -L. -lm
test_ft8: $(BUILD_DIR)/test/test.o libft8.a
$(CC) $(CFLAGS) -o $@ .build/test/test.o -lft8 -L. -lm
$(BUILD_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -o $@ -c $^
lib: libft8.a
libft8.a: $(FT8_OBJ) $(COMMON_OBJ)
$(AR) rc libft8.a $(FT8_OBJ) $(COMMON_OBJ)
+54
View File
@@ -0,0 +1,54 @@
# FT8 (and now FT4) library
C implementation of a lightweight FT8/FT4 decoder and encoder, mostly intended for experimental use on microcontrollers.
The intent of this library is to allow FT8/FT4 encoding and decoding in standalone environments (i.e. without a PC or RPi), e.g. automated beacons or SDR transceivers. It's also my learning process, optimization problem and source of fun.
The encoding process is relatively light on resources, and an Arduino should be perfectly capable of running this code.
The decoder is designed with memory and computing efficiency in mind, in order to be usable with a fast enough microcontroller. It is shown to be working on STM32F7 boards fast enough for real work, but the embedded application itself is beyond this repository. This repository provides an example decoder which can decode a 15-second WAV file on a desktop machine or SBC. The decoder needs to access the whole 15-second window in spectral magnitude representation (the window can be also shorter, and messages can have varying starting time within the window). The example FT8 decoder can work with slightly less than 200 KB of RAM.
# Current state
Currently the basic message set for establishing QSOs, as well as telemetry and free-text message modes are supported:
* CQ {call} {grid}, e.g. CQ CA0LL GG77
* CQ {xy} {call} {grid}, e.g. CQ JA CA0LL GG77
* {call} {call} {report}, e.g. CA0LL OT7ER R-07
* {call} {call} 73/RRR/RR73, e.g. OT7ER CA0LL 73
* Free-text messages (up to 13 characters from a limited alphabet) (decoding only, untested)
* Telemetry data (71 bits as 18 hex symbols)
Encoding and decoding works for both FT8 and FT4. For encoding and decoding, there is a console application provided for each, which serves mostly as test code, and could be a starting point for your potential application on an MCU. The console apps should run perfectly well on a RPi or a PC/Mac. I don't provide a concrete example for a particular MCU hardware here, since it would be very specific.
The code is not yet really a library, rather a collection of routines and example code.
# Future ideas
Incremental decoding (processing during the 15 second window) is something that I would like to explore, but haven't started.
These features are low on my priority list:
* Contest modes
* Compound callsigns with country prefixes and special callsigns
# What to do with it
You can generate 15-second WAV files with your own messages as a proof of concept or for testing purposes. They can either be played back or opened directly from WSJT-X. To do that, run ```make```. Then run ```gen_ft8``` (run it without parameters to check what parameters are supported). Currently messages are modulated at 1000-1050 Hz.
You can decode 15-second (or shorter) WAV files with ```decode_ft8```. This is only an example application and does not support live processing/recording. For that you could use third party code (PortAudio, for example).
# References and credits
Thanks goes out to:
* my contributors who have provided me with various improvements which have often been beyond my skill set.
* Robert Morris, AB1HL, whose Python code (https://github.com/rtmrtmrtmrtm/weakmon) inspired this and helped to test various parts of the code.
* Mark Borgerding for his FFT implementation (https://github.com/mborgerding/kissfft). I have included a portion of his code.
* WSJT-X authors, who developed a very interesting and novel communications protocol
The details of FT4 and FT8 procotols and decoding/encoding are described here: https://physics.princeton.edu/pulsar/k1jt/FT4_FT8_QEX.pdf
The public part of FT4/FT8 implementation is included in this repository under ft4_ft8_public.
Of course in moments of frustration I have looked up the original WSJT-X code, which is mostly written in Fortran (http://physics.princeton.edu/pulsar/K1JT/wsjtx.html). However, this library contains my own original DSP routines and a different implementation of the decoder which is suitable for resource-constrained embedded environments.
Karlis Goba,
YL3JG
+191
View File
@@ -0,0 +1,191 @@
#include "audio.h"
#include <stdio.h>
#include <string.h>
#ifdef USE_PORTAUDIO
#include <portaudio.h>
typedef struct
{
PaStream* instream;
} audio_context_t;
static audio_context_t audio_context;
static int audio_cb(void* inputBuffer, void* outputBuffer, unsigned long framesPerBuffer,
const PaStreamCallbackTimeInfo* timeInfo, PaStreamCallbackFlags statusFlags, void* userData)
{
audio_context_t* context = (audio_context_t*)userData;
float* samples_in = (float*)inputBuffer;
// PaTime time = data->startTime + timeInfo->inputBufferAdcTime;
printf("Callback with %ld samples\n", framesPerBuffer);
return 0;
}
void audio_list(void)
{
PaError pa_rc;
pa_rc = Pa_Initialize(); // Initialize PortAudio
if (pa_rc != paNoError)
{
printf("Error initializing PortAudio.\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return;
}
int numDevices;
numDevices = Pa_GetDeviceCount();
if (numDevices < 0)
{
printf("ERROR: Pa_CountDevices returned 0x%x\n", numDevices);
return;
}
printf("%d audio devices found:\n", numDevices);
for (int i = 0; i < numDevices; i++)
{
const PaDeviceInfo* deviceInfo = Pa_GetDeviceInfo(i);
PaStreamParameters inputParameters = {
.device = i,
.channelCount = 1, // 1 = mono, 2 = stereo
.sampleFormat = paFloat32,
.suggestedLatency = 0.2,
.hostApiSpecificStreamInfo = NULL
};
double sample_rate = 12000; // sample rate (frames per second)
pa_rc = Pa_IsFormatSupported(&inputParameters, NULL, sample_rate);
printf("%d: [%s] [%s]\n", (i + 1), deviceInfo->name, (pa_rc == paNoError) ? "OK" : "NOT SUPPORTED");
}
}
int audio_init(void)
{
PaError pa_rc;
pa_rc = Pa_Initialize(); // Initialize PortAudio
if (pa_rc != paNoError)
{
printf("Error initializing PortAudio.\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
Pa_Terminate(); // I don't think we need this but...
return -1;
}
return 0;
}
int audio_open(const char* name)
{
PaError pa_rc;
audio_context.instream = NULL;
PaDeviceIndex ndevice_in = -1;
int numDevices = Pa_GetDeviceCount();
for (int i = 0; i < numDevices; i++)
{
const PaDeviceInfo* deviceInfo = Pa_GetDeviceInfo(i);
if (0 == strcmp(deviceInfo->name, name))
{
ndevice_in = i;
break;
}
}
if (ndevice_in < 0)
{
printf("Could not find device [%s].\n", name);
audio_list();
return -1;
}
unsigned long nfpb = 1920 / 4; // frames per buffer
double sample_rate = 12000; // sample rate (frames per second)
PaStreamParameters inputParameters = {
.device = ndevice_in,
.channelCount = 1, // 1 = mono, 2 = stereo
.sampleFormat = paFloat32,
.suggestedLatency = 0.2,
.hostApiSpecificStreamInfo = NULL
};
// Test if this configuration actually works, so we do not run into an ugly assertion
pa_rc = Pa_IsFormatSupported(&inputParameters, NULL, sample_rate);
if (pa_rc != paNoError)
{
printf("Error opening input audio stream.\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return -2;
}
PaStream* instream;
pa_rc = Pa_OpenStream(
&instream, // address of stream
&inputParameters,
NULL,
sample_rate, // Sample rate
nfpb, // Frames per buffer
paNoFlag,
NULL /*(PaStreamCallback*)audio_cb*/, // Callback routine
NULL /*(void*)&audio_context*/); // address of data structure
if (pa_rc != paNoError)
{ // We should have no error here usually
printf("Error opening input audio stream:\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return -3;
}
// printf("Successfully opened audio input.\n");
pa_rc = Pa_StartStream(instream); // Start input stream
if (pa_rc != paNoError)
{
printf("Error starting input audio stream!\n");
printf("\tErrortext: %s\n\tNumber: %d\n", Pa_GetErrorText(pa_rc), pa_rc);
return -4;
}
audio_context.instream = instream;
// while (Pa_IsStreamActive(instream))
// {
// Pa_Sleep(100);
// }
// Pa_AbortStream(instream); // Abort stream
// Pa_CloseStream(instream); // Close stream, we're done.
return 0;
}
int audio_read(float* buffer, int num_samples)
{
PaError pa_rc;
pa_rc = Pa_ReadStream(audio_context.instream, (void*)buffer, num_samples);
return 0;
}
#else
int audio_init(void)
{
return -1;
}
void audio_list(void)
{
}
int audio_open(const char* name)
{
return -1;
}
int audio_read(float* buffer, int num_samples)
{
return -1;
}
#endif
+18
View File
@@ -0,0 +1,18 @@
#ifndef _INCLUDE_AUDIO_H_
#define _INCLUDE_AUDIO_H_
#ifdef __cplusplus
extern "C"
{
#endif
int audio_init(void);
void audio_list(void);
int audio_open(const char* name);
int audio_read(float* buffer, int num_samples);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_AUDIO_H_
+3
View File
@@ -0,0 +1,3 @@
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
+261
View File
@@ -0,0 +1,261 @@
#include "monitor.h"
#include <common/common.h>
#define LOG_LEVEL LOG_INFO
#include <ft8/debug.h>
#include <stdlib.h>
static float hann_i(int i, int N)
{
float x = sinf((float)M_PI * i / N);
return x * x;
}
// static float hamming_i(int i, int N)
// {
// const float a0 = (float)25 / 46;
// const float a1 = 1 - a0;
// float x1 = cosf(2 * (float)M_PI * i / N);
// return a0 - a1 * x1;
// }
// static float blackman_i(int i, int N)
// {
// const float alpha = 0.16f; // or 2860/18608
// const float a0 = (1 - alpha) / 2;
// const float a1 = 1.0f / 2;
// const float a2 = alpha / 2;
// float x1 = cosf(2 * (float)M_PI * i / N);
// float x2 = 2 * x1 * x1 - 1; // Use double angle formula
// return a0 - a1 * x1 + a2 * x2;
// }
static void waterfall_init(ftx_waterfall_t* me, int max_blocks, int num_bins, int time_osr, int freq_osr)
{
size_t mag_size = max_blocks * time_osr * freq_osr * num_bins * sizeof(me->mag[0]);
me->max_blocks = max_blocks;
me->num_blocks = 0;
me->num_bins = num_bins;
me->time_osr = time_osr;
me->freq_osr = freq_osr;
me->block_stride = (time_osr * freq_osr * num_bins);
me->mag = (WF_ELEM_T*)malloc(mag_size);
LOG(LOG_DEBUG, "Waterfall size = %zu\n", mag_size);
}
static void waterfall_free(ftx_waterfall_t* me)
{
free(me->mag);
}
void monitor_init(monitor_t* me, const monitor_config_t* cfg)
{
float slot_time = ftx_protocol_slot_time(cfg->protocol);
float symbol_period = ftx_protocol_symbol_period(cfg->protocol);
// Compute DSP parameters that depend on the sample rate
me->block_size = (int)(cfg->sample_rate * symbol_period); // samples corresponding to one FSK symbol
me->subblock_size = me->block_size / cfg->time_osr;
me->nfft = me->block_size * cfg->freq_osr;
me->fft_norm = 2.0f / me->nfft;
// const int len_window = 1.8f * me->block_size; // hand-picked and optimized
me->window = (float*)malloc(me->nfft * sizeof(me->window[0]));
for (int i = 0; i < me->nfft; ++i)
{
// window[i] = 1;
me->window[i] = me->fft_norm * hann_i(i, me->nfft);
// me->window[i] = blackman_i(i, me->nfft);
// me->window[i] = hamming_i(i, me->nfft);
// me->window[i] = (i < len_window) ? hann_i(i, len_window) : 0;
}
me->last_frame = (float*)calloc(me->nfft, sizeof(me->last_frame[0]));
LOG(LOG_INFO, "Block size = %d\n", me->block_size);
LOG(LOG_INFO, "Subblock size = %d\n", me->subblock_size);
size_t fft_work_size = 0;
kiss_fftr_alloc(me->nfft, 0, 0, &fft_work_size);
me->fft_work = malloc(fft_work_size);
me->fft_cfg = kiss_fftr_alloc(me->nfft, 0, me->fft_work, &fft_work_size);
LOG(LOG_INFO, "N_FFT = %d\n", me->nfft);
LOG(LOG_DEBUG, "FFT work area = %zu\n", fft_work_size);
#ifdef WATERFALL_USE_PHASE
me->nifft = 64; // Gives 200 Hz sample rate for FT8 (160ms symbol period)
size_t ifft_work_size = 0;
kiss_fft_alloc(me->nifft, 1, 0, &ifft_work_size);
me->ifft_work = malloc(ifft_work_size);
me->ifft_cfg = kiss_fft_alloc(me->nifft, 1, me->ifft_work, &ifft_work_size);
LOG(LOG_INFO, "N_iFFT = %d\n", me->nifft);
LOG(LOG_DEBUG, "iFFT work area = %zu\n", ifft_work_size);
#endif
// Allocate enough blocks to fit the entire FT8/FT4 slot in memory
const int max_blocks = (int)(slot_time / symbol_period);
// Keep only FFT bins in the specified frequency range (f_min/f_max)
me->min_bin = (int)(cfg->f_min * symbol_period);
me->max_bin = (int)(cfg->f_max * symbol_period) + 1;
const int num_bins = me->max_bin - me->min_bin;
waterfall_init(&me->wf, max_blocks, num_bins, cfg->time_osr, cfg->freq_osr);
me->wf.protocol = cfg->protocol;
me->symbol_period = symbol_period;
me->max_mag = -120.0f;
}
void monitor_free(monitor_t* me)
{
waterfall_free(&me->wf);
free(me->fft_work);
free(me->last_frame);
free(me->window);
}
void monitor_reset(monitor_t* me)
{
me->wf.num_blocks = 0;
me->max_mag = -120.0f;
}
// Compute FFT magnitudes (log wf) for a frame in the signal and update waterfall data
void monitor_process(monitor_t* me, const float* frame)
{
// Check if we can still store more waterfall data
if (me->wf.num_blocks >= me->wf.max_blocks)
return;
int offset = me->wf.num_blocks * me->wf.block_stride;
int frame_pos = 0;
// Loop over block subdivisions
for (int time_sub = 0; time_sub < me->wf.time_osr; ++time_sub)
{
kiss_fft_scalar timedata[me->nfft];
kiss_fft_cpx freqdata[me->nfft / 2 + 1];
// Shift the new data into analysis frame
for (int pos = 0; pos < me->nfft - me->subblock_size; ++pos)
{
me->last_frame[pos] = me->last_frame[pos + me->subblock_size];
}
for (int pos = me->nfft - me->subblock_size; pos < me->nfft; ++pos)
{
me->last_frame[pos] = frame[frame_pos];
++frame_pos;
}
// Do DFT of windowed analysis frame
for (int pos = 0; pos < me->nfft; ++pos)
{
timedata[pos] = me->window[pos] * me->last_frame[pos];
}
kiss_fftr(me->fft_cfg, timedata, freqdata);
// Loop over possible frequency OSR offsets
for (int freq_sub = 0; freq_sub < me->wf.freq_osr; ++freq_sub)
{
for (int bin = me->min_bin; bin < me->max_bin; ++bin)
{
int src_bin = (bin * me->wf.freq_osr) + freq_sub;
float mag2 = (freqdata[src_bin].i * freqdata[src_bin].i) + (freqdata[src_bin].r * freqdata[src_bin].r);
float db = 10.0f * log10f(1E-12f + mag2);
#ifdef WATERFALL_USE_PHASE
// Save the magnitude in dB and phase in radians
float phase = atan2f(freqdata[src_bin].i, freqdata[src_bin].r);
me->wf.mag[offset].mag = db;
me->wf.mag[offset].phase = phase;
#else
// Scale decibels to unsigned 8-bit range and clamp the value
// Range 0-240 covers -120..0 dB in 0.5 dB steps
int scaled = (int)(2 * db + 240);
me->wf.mag[offset] = (scaled < 0) ? 0 : ((scaled > 255) ? 255 : scaled);
#endif
++offset;
if (db > me->max_mag)
me->max_mag = db;
}
}
}
++me->wf.num_blocks;
}
#ifdef WATERFALL_USE_PHASE
void monitor_resynth(const monitor_t* me, const ftx_candidate_t* candidate, float* signal)
{
const int num_ifft = me->nifft;
const int num_shift = num_ifft / 2;
const int taper_width = 4;
// Starting offset is 3 subblocks due to analysis buffer loading
int offset = 1; // candidate->time_offset;
offset = (offset * me->wf.time_osr) + 1; // + candidate->time_sub;
offset = (offset * me->wf.freq_osr); // + candidate->freq_sub;
offset = (offset * me->wf.num_bins); // + candidate->freq_offset;
WF_ELEM_T* el = me->wf.mag + offset;
// DFT frequency data - initialize to zero
kiss_fft_cpx freqdata[num_ifft];
for (int i = 0; i < num_ifft; ++i)
{
freqdata[i].r = 0;
freqdata[i].i = 0;
}
int pos = 0;
for (int num_block = 1; num_block < me->wf.num_blocks; ++num_block)
{
// Extract frequency data around the selected candidate only
for (int i = candidate->freq_offset - taper_width - 1; i < candidate->freq_offset + 8 + taper_width - 1; ++i)
{
if ((i >= 0) && (i < me->wf.num_bins))
{
int tgt_bin = (me->wf.freq_osr * (i - candidate->freq_offset) + num_ifft) % num_ifft;
float weight = 1.0f;
if (i < candidate->freq_offset)
{
weight = ((i - candidate->freq_offset) + taper_width) / (float)taper_width;
}
else if (i > candidate->freq_offset + 7)
{
weight = ((candidate->freq_offset + 7 - i) + taper_width) / (float)taper_width;
}
// Convert (dB magnitude, phase) to (real, imaginary)
float mag = powf(10.0f, el[i].mag / 20) / 2 * weight;
freqdata[tgt_bin].r = mag * cosf(el[i].phase);
freqdata[tgt_bin].i = mag * sinf(el[i].phase);
int i2 = i + me->wf.num_bins;
tgt_bin = (tgt_bin + 1) % num_ifft;
float mag2 = powf(10.0f, el[i2].mag / 20) / 2 * weight;
freqdata[tgt_bin].r = mag2 * cosf(el[i2].phase);
freqdata[tgt_bin].i = mag2 * sinf(el[i2].phase);
}
}
// Compute inverse DFT and overlap-add the waveform
kiss_fft_cpx timedata[num_ifft];
kiss_fft(me->ifft_cfg, freqdata, timedata);
for (int i = 0; i < num_ifft; ++i)
{
signal[pos + i] += timedata[i].i;
}
// Move to the next symbol
el += me->wf.block_stride;
pos += num_shift;
}
}
#endif
+62
View File
@@ -0,0 +1,62 @@
#ifndef _INCLUDE_MONITOR_H_
#define _INCLUDE_MONITOR_H_
#ifdef __cplusplus
extern "C"
{
#endif
#include <ft8/decode.h>
#include <fft/kiss_fftr.h>
/// Configuration options for FT4/FT8 monitor
typedef struct
{
float f_min; ///< Lower frequency bound for analysis
float f_max; ///< Upper frequency bound for analysis
int sample_rate; ///< Sample rate in Hertz
int time_osr; ///< Number of time subdivisions
int freq_osr; ///< Number of frequency subdivisions
ftx_protocol_t protocol; ///< Protocol: FT4 or FT8
} monitor_config_t;
/// FT4/FT8 monitor object that manages DSP processing of incoming audio data
/// and prepares a waterfall object
typedef struct
{
float symbol_period; ///< FT4/FT8 symbol period in seconds
int min_bin; ///< First FFT bin in the frequency range (begin)
int max_bin; ///< First FFT bin outside the frequency range (end)
int block_size; ///< Number of samples per symbol (block)
int subblock_size; ///< Analysis shift size (number of samples)
int nfft; ///< FFT size
float fft_norm; ///< FFT normalization factor
float* window; ///< Window function for STFT analysis (nfft samples)
float* last_frame; ///< Current STFT analysis frame (nfft samples)
ftx_waterfall_t wf; ///< Waterfall object
float max_mag; ///< Maximum detected magnitude (debug stats)
// KISS FFT housekeeping variables
void* fft_work; ///< Work area required by Kiss FFT
kiss_fftr_cfg fft_cfg; ///< Kiss FFT housekeeping object
#ifdef WATERFALL_USE_PHASE
int nifft; ///< iFFT size
void* ifft_work; ///< Work area required by inverse Kiss FFT
kiss_fft_cfg ifft_cfg; ///< Inverse Kiss FFT housekeeping object
#endif
} monitor_t;
void monitor_init(monitor_t* me, const monitor_config_t* cfg);
void monitor_reset(monitor_t* me);
void monitor_process(monitor_t* me, const float* frame);
void monitor_free(monitor_t* me);
#ifdef WATERFALL_USE_PHASE
void monitor_resynth(const monitor_t* me, const ftx_candidate_t* candidate, float* signal);
#endif
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_MONITOR_H_
+133
View File
@@ -0,0 +1,133 @@
#include "wave.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <stdint.h>
// Save signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int save_wav(const float* signal, int num_samples, int sample_rate, const char* path)
{
char subChunk1ID[4] = { 'f', 'm', 't', ' ' };
uint32_t subChunk1Size = 16; // 16 for PCM
uint16_t audioFormat = 1; // PCM = 1
uint16_t numChannels = 1;
uint16_t bitsPerSample = 16;
uint32_t sampleRate = sample_rate;
uint16_t blockAlign = numChannels * bitsPerSample / 8;
uint32_t byteRate = sampleRate * blockAlign;
char subChunk2ID[4] = { 'd', 'a', 't', 'a' };
uint32_t subChunk2Size = num_samples * blockAlign;
char chunkID[4] = { 'R', 'I', 'F', 'F' };
uint32_t chunkSize = 4 + (8 + subChunk1Size) + (8 + subChunk2Size);
char format[4] = { 'W', 'A', 'V', 'E' };
int16_t* raw_data = (int16_t*)malloc(num_samples * blockAlign);
for (int i = 0; i < num_samples; i++)
{
float x = signal[i];
if (x > 1.0)
x = 1.0;
else if (x < -1.0)
x = -1.0;
raw_data[i] = (int)(0.5 + (x * 32767.0));
}
FILE* f = fopen(path, "wb");
if (f == NULL)
return -1;
// NOTE: works only on little-endian architecture
fwrite(chunkID, sizeof(chunkID), 1, f);
fwrite(&chunkSize, sizeof(chunkSize), 1, f);
fwrite(format, sizeof(format), 1, f);
fwrite(subChunk1ID, sizeof(subChunk1ID), 1, f);
fwrite(&subChunk1Size, sizeof(subChunk1Size), 1, f);
fwrite(&audioFormat, sizeof(audioFormat), 1, f);
fwrite(&numChannels, sizeof(numChannels), 1, f);
fwrite(&sampleRate, sizeof(sampleRate), 1, f);
fwrite(&byteRate, sizeof(byteRate), 1, f);
fwrite(&blockAlign, sizeof(blockAlign), 1, f);
fwrite(&bitsPerSample, sizeof(bitsPerSample), 1, f);
fwrite(subChunk2ID, sizeof(subChunk2ID), 1, f);
fwrite(&subChunk2Size, sizeof(subChunk2Size), 1, f);
fwrite(raw_data, blockAlign, num_samples, f);
fclose(f);
free(raw_data);
return 0;
}
// Load signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int load_wav(float* signal, int* num_samples, int* sample_rate, const char* path)
{
char subChunk1ID[4]; // = {'f', 'm', 't', ' '};
uint32_t subChunk1Size; // = 16; // 16 for PCM
uint16_t audioFormat; // = 1; // PCM = 1
uint16_t numChannels; // = 1;
uint16_t bitsPerSample; // = 16;
uint32_t sampleRate;
uint16_t blockAlign; // = numChannels * bitsPerSample / 8;
uint32_t byteRate; // = sampleRate * blockAlign;
char subChunk2ID[4]; // = {'d', 'a', 't', 'a'};
uint32_t subChunk2Size; // = num_samples * blockAlign;
char chunkID[4]; // = {'R', 'I', 'F', 'F'};
uint32_t chunkSize; // = 4 + (8 + subChunk1Size) + (8 + subChunk2Size);
char format[4]; // = {'W', 'A', 'V', 'E'};
FILE* f = fopen(path, "rb");
if (f == NULL)
return -1;
// NOTE: works only on little-endian architecture
fread((void*)chunkID, sizeof(chunkID), 1, f);
fread((void*)&chunkSize, sizeof(chunkSize), 1, f);
fread((void*)format, sizeof(format), 1, f);
fread((void*)subChunk1ID, sizeof(subChunk1ID), 1, f);
fread((void*)&subChunk1Size, sizeof(subChunk1Size), 1, f);
if (subChunk1Size != 16)
return -2;
fread((void*)&audioFormat, sizeof(audioFormat), 1, f);
fread((void*)&numChannels, sizeof(numChannels), 1, f);
fread((void*)&sampleRate, sizeof(sampleRate), 1, f);
fread((void*)&byteRate, sizeof(byteRate), 1, f);
fread((void*)&blockAlign, sizeof(blockAlign), 1, f);
fread((void*)&bitsPerSample, sizeof(bitsPerSample), 1, f);
if (audioFormat != 1 || numChannels != 1 || bitsPerSample != 16)
return -3;
fread((void*)subChunk2ID, sizeof(subChunk2ID), 1, f);
fread((void*)&subChunk2Size, sizeof(subChunk2Size), 1, f);
if (subChunk2Size / blockAlign > *num_samples)
return -4;
*num_samples = subChunk2Size / blockAlign;
*sample_rate = sampleRate;
int16_t* raw_data = (int16_t*)malloc(*num_samples * blockAlign);
fread((void*)raw_data, blockAlign, *num_samples, f);
for (int i = 0; i < *num_samples; i++)
{
signal[i] = raw_data[i] / 32768.0f;
}
free(raw_data);
fclose(f);
return 0;
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef _INCLUDE_WAVE_H_
#define _INCLUDE_WAVE_H_
#ifdef __cplusplus
extern "C"
{
#endif
// Save signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int save_wav(const float* signal, int num_samples, int sample_rate, const char* path);
// Load signal in floating point format (-1 .. +1) as a WAVE file using 16-bit signed integers.
int load_wav(float* signal, int* num_samples, int* sample_rate, const char* path);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_WAVE_H_
+393
View File
@@ -0,0 +1,393 @@
#define _POSIX_C_SOURCE 199309L
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <stdbool.h>
#include <time.h>
#include <ft8/decode.h>
#include <ft8/encode.h>
#include <ft8/message.h>
#include <common/common.h>
#include <common/wave.h>
#include <common/monitor.h>
#include <common/audio.h>
#define LOG_LEVEL LOG_INFO
#include <ft8/debug.h>
const int kMin_score = 10; // Minimum sync score threshold for candidates
const int kMax_candidates = 140;
const int kLDPC_iterations = 25;
const int kMax_decoded_messages = 50;
const int kFreq_osr = 2; // Frequency oversampling rate (bin subdivision)
const int kTime_osr = 2; // Time oversampling rate (symbol subdivision)
void usage(const char* error_msg)
{
if (error_msg != NULL)
{
fprintf(stderr, "ERROR: %s\n", error_msg);
}
fprintf(stderr, "Usage: decode_ft8 [-list|([-ft4] [INPUT|-dev DEVICE])]\n\n");
fprintf(stderr, "Decode a 15-second (or slighly shorter) WAV file.\n");
}
#define CALLSIGN_HASHTABLE_SIZE 256
static struct
{
char callsign[12]; ///> Up to 11 symbols of callsign + trailing zeros (always filled)
uint32_t hash; ///> 8 MSBs contain the age of callsign; 22 LSBs contain hash value
} callsign_hashtable[CALLSIGN_HASHTABLE_SIZE];
static int callsign_hashtable_size;
void hashtable_init(void)
{
callsign_hashtable_size = 0;
memset(callsign_hashtable, 0, sizeof(callsign_hashtable));
}
void hashtable_cleanup(uint8_t max_age)
{
for (int idx_hash = 0; idx_hash < CALLSIGN_HASHTABLE_SIZE; ++idx_hash)
{
if (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
uint8_t age = (uint8_t)(callsign_hashtable[idx_hash].hash >> 24);
if (age > max_age)
{
LOG(LOG_INFO, "Removing [%s] from hash table, age = %d\n", callsign_hashtable[idx_hash].callsign, age);
// free the hash entry
callsign_hashtable[idx_hash].callsign[0] = '\0';
callsign_hashtable[idx_hash].hash = 0;
callsign_hashtable_size--;
}
else
{
// increase callsign age
callsign_hashtable[idx_hash].hash = (((uint32_t)age + 1u) << 24) | (callsign_hashtable[idx_hash].hash & 0x3FFFFFu);
}
}
}
}
void hashtable_add(const char* callsign, uint32_t hash)
{
uint16_t hash10 = (hash >> 12) & 0x3FFu;
int idx_hash = (hash10 * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) == hash) && (0 == strcmp(callsign_hashtable[idx_hash].callsign, callsign)))
{
// reset age
callsign_hashtable[idx_hash].hash &= 0x3FFFFFu;
LOG(LOG_DEBUG, "Found a duplicate [%s]\n", callsign);
return;
}
else
{
LOG(LOG_DEBUG, "Hash table clash!\n");
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
}
callsign_hashtable_size++;
strncpy(callsign_hashtable[idx_hash].callsign, callsign, 11);
callsign_hashtable[idx_hash].callsign[11] = '\0';
callsign_hashtable[idx_hash].hash = hash;
}
bool hashtable_lookup(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign)
{
uint8_t hash_shift = (hash_type == FTX_CALLSIGN_HASH_10_BITS) ? 12 : (hash_type == FTX_CALLSIGN_HASH_12_BITS ? 10 : 0);
uint16_t hash10 = (hash >> (12 - hash_shift)) & 0x3FFu;
int idx_hash = (hash10 * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if (((callsign_hashtable[idx_hash].hash & 0x3FFFFFu) >> hash_shift) == hash)
{
strcpy(callsign, callsign_hashtable[idx_hash].callsign);
return true;
}
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
callsign[0] = '\0';
return false;
}
ftx_callsign_hash_interface_t hash_if = {
.lookup_hash = hashtable_lookup,
.save_hash = hashtable_add
};
void decode(const monitor_t* mon, struct tm* tm_slot_start)
{
const ftx_waterfall_t* wf = &mon->wf;
// Find top candidates by Costas sync score and localize them in time and frequency
ftx_candidate_t candidate_list[kMax_candidates];
int num_candidates = ftx_find_candidates(wf, kMax_candidates, candidate_list, kMin_score);
// Hash table for decoded messages (to check for duplicates)
int num_decoded = 0;
ftx_message_t decoded[kMax_decoded_messages];
ftx_message_t* decoded_hashtable[kMax_decoded_messages];
// Initialize hash table pointers
for (int i = 0; i < kMax_decoded_messages; ++i)
{
decoded_hashtable[i] = NULL;
}
// Go over candidates and attempt to decode messages
for (int idx = 0; idx < num_candidates; ++idx)
{
const ftx_candidate_t* cand = &candidate_list[idx];
float freq_hz = (mon->min_bin + cand->freq_offset + (float)cand->freq_sub / wf->freq_osr) / mon->symbol_period;
float time_sec = (cand->time_offset + (float)cand->time_sub / wf->time_osr) * mon->symbol_period;
#ifdef WATERFALL_USE_PHASE
// int resynth_len = 12000 * 16;
// float resynth_signal[resynth_len];
// for (int pos = 0; pos < resynth_len; ++pos)
// {
// resynth_signal[pos] = 0;
// }
// monitor_resynth(mon, cand, resynth_signal);
// char resynth_path[80];
// sprintf(resynth_path, "resynth_%04f_%02.1f.wav", freq_hz, time_sec);
// save_wav(resynth_signal, resynth_len, 12000, resynth_path);
#endif
ftx_message_t message;
ftx_decode_status_t status;
if (!ftx_decode_candidate(wf, cand, kLDPC_iterations, &message, &status))
{
if (status.ldpc_errors > 0)
{
LOG(LOG_DEBUG, "LDPC decode: %d errors\n", status.ldpc_errors);
}
else if (status.crc_calculated != status.crc_extracted)
{
LOG(LOG_DEBUG, "CRC mismatch!\n");
}
continue;
}
LOG(LOG_DEBUG, "Checking hash table for %4.1fs / %4.1fHz [%d]...\n", time_sec, freq_hz, cand->score);
int idx_hash = message.hash % kMax_decoded_messages;
bool found_empty_slot = false;
bool found_duplicate = false;
do
{
if (decoded_hashtable[idx_hash] == NULL)
{
LOG(LOG_DEBUG, "Found an empty slot\n");
found_empty_slot = true;
}
else if ((decoded_hashtable[idx_hash]->hash == message.hash) && (0 == memcmp(decoded_hashtable[idx_hash]->payload, message.payload, sizeof(message.payload))))
{
LOG(LOG_DEBUG, "Found a duplicate!\n");
found_duplicate = true;
}
else
{
LOG(LOG_DEBUG, "Hash table clash!\n");
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % kMax_decoded_messages;
}
} while (!found_empty_slot && !found_duplicate);
if (found_empty_slot)
{
// Fill the empty hashtable slot
memcpy(&decoded[idx_hash], &message, sizeof(message));
decoded_hashtable[idx_hash] = &decoded[idx_hash];
++num_decoded;
char text[FTX_MAX_MESSAGE_LENGTH];
ftx_message_offsets_t offsets;
ftx_message_rc_t unpack_status = ftx_message_decode(&message, &hash_if, text, &offsets);
if (unpack_status != FTX_MESSAGE_RC_OK)
{
snprintf(text, sizeof(text), "Error [%d] while unpacking!", (int)unpack_status);
}
// Fake WSJT-X-like output for now
float snr = cand->score * 0.5f; // TODO: compute better approximation of SNR
printf("%02d%02d%02d %+05.1f %+4.2f %4.0f ~ %s\n",
tm_slot_start->tm_hour, tm_slot_start->tm_min, tm_slot_start->tm_sec,
snr, time_sec, freq_hz, text);
}
}
LOG(LOG_INFO, "Decoded %d messages, callsign hashtable size %d\n", num_decoded, callsign_hashtable_size);
hashtable_cleanup(10);
}
int main(int argc, char** argv)
{
// Accepted arguments
const char* wav_path = NULL;
const char* dev_name = NULL;
ftx_protocol_t protocol = FTX_PROTOCOL_FT8;
float time_shift = 0.8;
// Parse arguments one by one
int arg_idx = 1;
while (arg_idx < argc)
{
// Check if the current argument is an option (-xxx)
if (argv[arg_idx][0] == '-')
{
// Check agaist valid options
if (0 == strcmp(argv[arg_idx], "-ft4"))
{
protocol = FTX_PROTOCOL_FT4;
}
else if (0 == strcmp(argv[arg_idx], "-list"))
{
audio_init();
audio_list();
return 0;
}
else if (0 == strcmp(argv[arg_idx], "-dev"))
{
if (arg_idx + 1 < argc)
{
++arg_idx;
dev_name = argv[arg_idx];
}
else
{
usage("Expected an audio device name after -dev");
return -1;
}
}
else
{
usage("Unknown command line option");
return -1;
}
}
else
{
if (wav_path == NULL)
{
wav_path = argv[arg_idx];
}
else
{
usage("Multiple positional arguments");
return -1;
}
}
++arg_idx;
}
// Check if all mandatory arguments have been received
if (wav_path == NULL && dev_name == NULL)
{
usage("Expected either INPUT file path or DEVICE name");
return -1;
}
float slot_period = ((protocol == FTX_PROTOCOL_FT8) ? FT8_SLOT_TIME : FT4_SLOT_TIME);
int sample_rate = 12000;
int num_samples = slot_period * sample_rate;
float signal[num_samples];
bool is_live = false;
if (wav_path != NULL)
{
int rc = load_wav(signal, &num_samples, &sample_rate, wav_path);
if (rc < 0)
{
LOG(LOG_ERROR, "ERROR: cannot load wave file %s\n", wav_path);
return -1;
}
LOG(LOG_INFO, "Sample rate %d Hz, %d samples, %.3f seconds\n", sample_rate, num_samples, (double)num_samples / sample_rate);
}
else if (dev_name != NULL)
{
audio_init();
audio_open(dev_name);
num_samples = (slot_period - 0.4f) * sample_rate;
is_live = true;
}
// Compute FFT over the whole signal and store it
monitor_t mon;
monitor_config_t mon_cfg = {
.f_min = 200,
.f_max = 3000,
.sample_rate = sample_rate,
.time_osr = kTime_osr,
.freq_osr = kFreq_osr,
.protocol = protocol
};
hashtable_init();
monitor_init(&mon, &mon_cfg);
LOG(LOG_DEBUG, "Waterfall allocated %d symbols\n", mon.wf.max_blocks);
do
{
struct tm tm_slot_start = { 0 };
if (is_live)
{
// Wait for the start of time slot
while (true)
{
struct timespec spec;
clock_gettime(CLOCK_REALTIME, &spec);
double time = (double)spec.tv_sec + (spec.tv_nsec / 1e9);
double time_within_slot = fmod(time - time_shift, slot_period);
if (time_within_slot > slot_period / 4)
{
audio_read(signal, mon.block_size);
}
else
{
time_t time_slot_start = (time_t)(time - time_within_slot);
gmtime_r(&time_slot_start, &tm_slot_start);
LOG(LOG_INFO, "Time within slot %02d%02d%02d: %.3f s\n", tm_slot_start.tm_hour,
tm_slot_start.tm_min, tm_slot_start.tm_sec, time_within_slot);
break;
}
}
}
// Process and accumulate audio data in a monitor/waterfall instance
for (int frame_pos = 0; frame_pos + mon.block_size <= num_samples; frame_pos += mon.block_size)
{
if (dev_name != NULL)
{
audio_read(signal + frame_pos, mon.block_size);
}
// LOG(LOG_DEBUG, "Frame pos: %.3fs\n", (float)(frame_pos + mon.block_size) / sample_rate);
fprintf(stderr, "#");
// Process the waveform data frame by frame - you could have a live loop here with data from an audio device
monitor_process(&mon, signal + frame_pos);
}
fprintf(stderr, "\n");
LOG(LOG_DEBUG, "Waterfall accumulated %d symbols\n", mon.wf.num_blocks);
LOG(LOG_INFO, "Max magnitude: %.1f dB\n", mon.max_mag);
// Decode accumulated data (containing slightly less than a full time slot)
decode(&mon, &tm_slot_start);
// Reset internal variables for the next time slot
monitor_reset(&mon);
} while (is_live);
monitor_free(&mon);
return 0;
}
+189
View File
@@ -0,0 +1,189 @@
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <stdbool.h>
#include "common/common.h"
#include "common/wave.h"
#include "ft8/message.h"
#include "ft8/encode.h"
#include "ft8/constants.h"
#define LOG_LEVEL LOG_INFO
#include "ft8/debug.h"
#define FT8_SYMBOL_BT 2.0f ///< symbol smoothing filter bandwidth factor (BT)
#define FT4_SYMBOL_BT 1.0f ///< symbol smoothing filter bandwidth factor (BT)
#define GFSK_CONST_K 5.336446f ///< == pi * sqrt(2 / log(2))
/// Computes a GFSK smoothing pulse.
/// The pulse is theoretically infinitely long, however, here it's truncated at 3 times the symbol length.
/// This means the pulse array has to have space for 3*n_spsym elements.
/// @param[in] n_spsym Number of samples per symbol
/// @param[in] b Shape parameter (values defined for FT8/FT4)
/// @param[out] pulse Output array of pulse samples
///
void gfsk_pulse(int n_spsym, float symbol_bt, float* pulse)
{
for (int i = 0; i < 3 * n_spsym; ++i)
{
float t = i / (float)n_spsym - 1.5f;
float arg1 = GFSK_CONST_K * symbol_bt * (t + 0.5f);
float arg2 = GFSK_CONST_K * symbol_bt * (t - 0.5f);
pulse[i] = (erff(arg1) - erff(arg2)) / 2;
}
}
/// Synthesize waveform data using GFSK phase shaping.
/// The output waveform will contain n_sym symbols.
/// @param[in] symbols Array of symbols (tones) (0-7 for FT8)
/// @param[in] n_sym Number of symbols in the symbol array
/// @param[in] f0 Audio frequency in Hertz for the symbol 0 (base frequency)
/// @param[in] symbol_bt Symbol smoothing filter bandwidth (2 for FT8, 1 for FT4)
/// @param[in] symbol_period Symbol period (duration), seconds
/// @param[in] signal_rate Sample rate of synthesized signal, Hertz
/// @param[out] signal Output array of signal waveform samples (should have space for n_sym*n_spsym samples)
///
void synth_gfsk(const uint8_t* symbols, int n_sym, float f0, float symbol_bt, float symbol_period, int signal_rate, float* signal)
{
int n_spsym = (int)(0.5f + signal_rate * symbol_period); // Samples per symbol
int n_wave = n_sym * n_spsym; // Number of output samples
float hmod = 1.0f;
LOG(LOG_DEBUG, "n_spsym = %d\n", n_spsym);
// Compute the smoothed frequency waveform.
// Length = (nsym+2)*n_spsym samples, first and last symbols extended
float dphi_peak = 2 * M_PI * hmod / n_spsym;
float dphi[n_wave + 2 * n_spsym];
// Shift frequency up by f0
for (int i = 0; i < n_wave + 2 * n_spsym; ++i)
{
dphi[i] = 2 * M_PI * f0 / signal_rate;
}
float pulse[3 * n_spsym];
gfsk_pulse(n_spsym, symbol_bt, pulse);
for (int i = 0; i < n_sym; ++i)
{
int ib = i * n_spsym;
for (int j = 0; j < 3 * n_spsym; ++j)
{
dphi[j + ib] += dphi_peak * symbols[i] * pulse[j];
}
}
// Add dummy symbols at beginning and end with tone values equal to 1st and last symbol, respectively
for (int j = 0; j < 2 * n_spsym; ++j)
{
dphi[j] += dphi_peak * pulse[j + n_spsym] * symbols[0];
dphi[j + n_sym * n_spsym] += dphi_peak * pulse[j] * symbols[n_sym - 1];
}
// Calculate and insert the audio waveform
float phi = 0;
for (int k = 0; k < n_wave; ++k)
{ // Don't include dummy symbols
signal[k] = sinf(phi);
phi = fmodf(phi + dphi[k + n_spsym], 2 * M_PI);
}
// Apply envelope shaping to the first and last symbols
int n_ramp = n_spsym / 8;
for (int i = 0; i < n_ramp; ++i)
{
float env = (1 - cosf(2 * M_PI * i / (2 * n_ramp))) / 2;
signal[i] *= env;
signal[n_wave - 1 - i] *= env;
}
}
void usage()
{
printf("Generate a 15-second WAV file encoding a given message.\n");
printf("Usage:\n");
printf("\n");
printf("gen_ft8 MESSAGE WAV_FILE [FREQUENCY]\n");
printf("\n");
printf("(Note that you might have to enclose your message in quote marks if it contains spaces)\n");
}
int main(int argc, char** argv)
{
// Expect two command-line arguments
if (argc < 3)
{
usage();
return -1;
}
const char* message = argv[1];
const char* wav_path = argv[2];
float frequency = 1000.0;
if (argc > 3)
{
frequency = atof(argv[3]);
}
bool is_ft4 = (argc > 4) && (0 == strcmp(argv[4], "-ft4"));
// First, pack the text data into binary message
ftx_message_t msg;
ftx_message_rc_t rc = ftx_message_encode(&msg, NULL, message);
if (rc != FTX_MESSAGE_RC_OK)
{
printf("Cannot parse message!\n");
printf("RC = %d\n", (int)rc);
return -2;
}
printf("Packed data: ");
for (int j = 0; j < 10; ++j)
{
printf("%02x ", msg.payload[j]);
}
printf("\n");
int num_tones = (is_ft4) ? FT4_NN : FT8_NN;
float symbol_period = (is_ft4) ? FT4_SYMBOL_PERIOD : FT8_SYMBOL_PERIOD;
float symbol_bt = (is_ft4) ? FT4_SYMBOL_BT : FT8_SYMBOL_BT;
float slot_time = (is_ft4) ? FT4_SLOT_TIME : FT8_SLOT_TIME;
// Second, encode the binary message as a sequence of FSK tones
uint8_t tones[num_tones]; // Array of 79 tones (symbols)
if (is_ft4)
{
ft4_encode(msg.payload, tones);
}
else
{
ft8_encode(msg.payload, tones);
}
printf("FSK tones: ");
for (int j = 0; j < num_tones; ++j)
{
printf("%d", tones[j]);
}
printf("\n");
// Third, convert the FSK tones into an audio signal
int sample_rate = 12000;
int num_samples = (int)(0.5f + num_tones * symbol_period * sample_rate); // Number of samples in the data signal
int num_silence = (slot_time * sample_rate - num_samples) / 2; // Silence padding at both ends to make 15 seconds
int num_total_samples = num_silence + num_samples + num_silence; // Number of samples in the padded signal
float signal[num_total_samples];
for (int i = 0; i < num_silence; i++)
{
signal[i] = 0;
signal[i + num_samples + num_silence] = 0;
}
// Synthesize waveform data (signal) and save it as WAV file
synth_gfsk(tones, num_tones, frequency, symbol_bt, symbol_period, sample_rate, signal + num_silence);
save_wav(signal, num_total_samples, sample_rate, wav_path);
return 0;
}
+158
View File
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2003-2010, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
/* kiss_fft.h
defines kiss_fft_scalar as either short or a float type
and defines
typedef struct { kiss_fft_scalar r; kiss_fft_scalar i; }kiss_fft_cpx; */
#include "kiss_fft.h"
#include <limits.h>
#define MAXFACTORS 32
/* e.g. an fft of length 128 has 4 factors
as far as kissfft is concerned
4*4*4*2
*/
struct kiss_fft_state{
int nfft;
int inverse;
int factors[2*MAXFACTORS];
kiss_fft_cpx twiddles[1];
};
/*
Explanation of macros dealing with complex math:
C_MUL(m,a,b) : m = a*b
C_FIXDIV( c , div ) : if a fixed point impl., c /= div. noop otherwise
C_SUB( res, a,b) : res = a - b
C_SUBFROM( res , a) : res -= a
C_ADDTO( res , a) : res += a
* */
#ifdef FIXED_POINT
#if (FIXED_POINT==32)
# define FRACBITS 31
# define SAMPPROD int64_t
#define SAMP_MAX 2147483647
#else
# define FRACBITS 15
# define SAMPPROD int32_t
#define SAMP_MAX 32767
#endif
#define SAMP_MIN -SAMP_MAX
#if defined(CHECK_OVERFLOW)
# define CHECK_OVERFLOW_OP(a,op,b) \
if ( (SAMPPROD)(a) op (SAMPPROD)(b) > SAMP_MAX || (SAMPPROD)(a) op (SAMPPROD)(b) < SAMP_MIN ) { \
fprintf(stderr,"WARNING:overflow @ " __FILE__ "(%d): (%d " #op" %d) = %ld\n",__LINE__,(a),(b),(SAMPPROD)(a) op (SAMPPROD)(b) ); }
#endif
# define smul(a,b) ( (SAMPPROD)(a)*(b) )
# define sround( x ) (kiss_fft_scalar)( ( (x) + (1<<(FRACBITS-1)) ) >> FRACBITS )
# define S_MUL(a,b) sround( smul(a,b) )
# define C_MUL(m,a,b) \
do{ (m).r = sround( smul((a).r,(b).r) - smul((a).i,(b).i) ); \
(m).i = sround( smul((a).r,(b).i) + smul((a).i,(b).r) ); }while(0)
# define DIVSCALAR(x,k) \
(x) = sround( smul( x, SAMP_MAX/k ) )
# define C_FIXDIV(c,div) \
do { DIVSCALAR( (c).r , div); \
DIVSCALAR( (c).i , div); }while (0)
# define C_MULBYSCALAR( c, s ) \
do{ (c).r = sround( smul( (c).r , s ) ) ;\
(c).i = sround( smul( (c).i , s ) ) ; }while(0)
#else /* not FIXED_POINT*/
# define S_MUL(a,b) ( (a)*(b) )
#define C_MUL(m,a,b) \
do{ (m).r = (a).r*(b).r - (a).i*(b).i;\
(m).i = (a).r*(b).i + (a).i*(b).r; }while(0)
# define C_FIXDIV(c,div) /* NOOP */
# define C_MULBYSCALAR( c, s ) \
do{ (c).r *= (s);\
(c).i *= (s); }while(0)
#endif
#ifndef CHECK_OVERFLOW_OP
# define CHECK_OVERFLOW_OP(a,op,b) /* noop */
#endif
#define C_ADD( res, a,b)\
do { \
CHECK_OVERFLOW_OP((a).r,+,(b).r)\
CHECK_OVERFLOW_OP((a).i,+,(b).i)\
(res).r=(a).r+(b).r; (res).i=(a).i+(b).i; \
}while(0)
#define C_SUB( res, a,b)\
do { \
CHECK_OVERFLOW_OP((a).r,-,(b).r)\
CHECK_OVERFLOW_OP((a).i,-,(b).i)\
(res).r=(a).r-(b).r; (res).i=(a).i-(b).i; \
}while(0)
#define C_ADDTO( res , a)\
do { \
CHECK_OVERFLOW_OP((res).r,+,(a).r)\
CHECK_OVERFLOW_OP((res).i,+,(a).i)\
(res).r += (a).r; (res).i += (a).i;\
}while(0)
#define C_SUBFROM( res , a)\
do {\
CHECK_OVERFLOW_OP((res).r,-,(a).r)\
CHECK_OVERFLOW_OP((res).i,-,(a).i)\
(res).r -= (a).r; (res).i -= (a).i; \
}while(0)
#ifdef FIXED_POINT
# define KISS_FFT_COS(phase) floor(.5+SAMP_MAX * cos (phase))
# define KISS_FFT_SIN(phase) floor(.5+SAMP_MAX * sin (phase))
# define HALF_OF(x) ((x)>>1)
#elif defined(USE_SIMD)
# define KISS_FFT_COS(phase) _mm_set1_ps( cos(phase) )
# define KISS_FFT_SIN(phase) _mm_set1_ps( sin(phase) )
# define HALF_OF(x) ((x)*_mm_set1_ps(.5))
#else
# define KISS_FFT_COS(phase) (kiss_fft_scalar) cos(phase)
# define KISS_FFT_SIN(phase) (kiss_fft_scalar) sin(phase)
# define HALF_OF(x) ((x)*.5)
#endif
#define kf_cexp(x,phase) \
do{ \
(x)->r = KISS_FFT_COS(phase);\
(x)->i = KISS_FFT_SIN(phase);\
}while(0)
/* a debugging function */
#define pcpx(c)\
fprintf(stderr,"%g + %gi\n",(double)((c)->r),(double)((c)->i) )
#ifdef KISS_FFT_USE_ALLOCA
// define this to allow use of alloca instead of malloc for temporary buffers
// Temporary buffers are used in two case:
// 1. FFT sizes that have "bad" factors. i.e. not 2,3 and 5
// 2. "in-place" FFTs. Notice the quotes, since kissfft does not really do an in-place transform.
#include <alloca.h>
#define KISS_FFT_TMP_ALLOC(nbytes) alloca(nbytes)
#define KISS_FFT_TMP_FREE(ptr)
#else
#define KISS_FFT_TMP_ALLOC(nbytes) KISS_FFT_MALLOC(nbytes)
#define KISS_FFT_TMP_FREE(ptr) KISS_FFT_FREE(ptr)
#endif
+402
View File
@@ -0,0 +1,402 @@
/*
* Copyright (c) 2003-2010, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#include "_kiss_fft_guts.h"
/* The guts header contains all the multiplication and addition macros that are defined for
fixed or floating point complex numbers. It also delares the kf_ internal functions.
*/
static void kf_bfly2(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
int m
)
{
kiss_fft_cpx * Fout2;
kiss_fft_cpx * tw1 = st->twiddles;
kiss_fft_cpx t;
Fout2 = Fout + m;
do{
C_FIXDIV(*Fout,2); C_FIXDIV(*Fout2,2);
C_MUL (t, *Fout2 , *tw1);
tw1 += fstride;
C_SUB( *Fout2 , *Fout , t );
C_ADDTO( *Fout , t );
++Fout2;
++Fout;
}while (--m);
}
static void kf_bfly4(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
const size_t m
)
{
kiss_fft_cpx *tw1,*tw2,*tw3;
kiss_fft_cpx scratch[6];
size_t k=m;
const size_t m2=2*m;
const size_t m3=3*m;
tw3 = tw2 = tw1 = st->twiddles;
do {
C_FIXDIV(*Fout,4); C_FIXDIV(Fout[m],4); C_FIXDIV(Fout[m2],4); C_FIXDIV(Fout[m3],4);
C_MUL(scratch[0],Fout[m] , *tw1 );
C_MUL(scratch[1],Fout[m2] , *tw2 );
C_MUL(scratch[2],Fout[m3] , *tw3 );
C_SUB( scratch[5] , *Fout, scratch[1] );
C_ADDTO(*Fout, scratch[1]);
C_ADD( scratch[3] , scratch[0] , scratch[2] );
C_SUB( scratch[4] , scratch[0] , scratch[2] );
C_SUB( Fout[m2], *Fout, scratch[3] );
tw1 += fstride;
tw2 += fstride*2;
tw3 += fstride*3;
C_ADDTO( *Fout , scratch[3] );
if(st->inverse) {
Fout[m].r = scratch[5].r - scratch[4].i;
Fout[m].i = scratch[5].i + scratch[4].r;
Fout[m3].r = scratch[5].r + scratch[4].i;
Fout[m3].i = scratch[5].i - scratch[4].r;
}else{
Fout[m].r = scratch[5].r + scratch[4].i;
Fout[m].i = scratch[5].i - scratch[4].r;
Fout[m3].r = scratch[5].r - scratch[4].i;
Fout[m3].i = scratch[5].i + scratch[4].r;
}
++Fout;
}while(--k);
}
static void kf_bfly3(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
size_t m
)
{
size_t k=m;
const size_t m2 = 2*m;
kiss_fft_cpx *tw1,*tw2;
kiss_fft_cpx scratch[5];
kiss_fft_cpx epi3;
epi3 = st->twiddles[fstride*m];
tw1=tw2=st->twiddles;
do{
C_FIXDIV(*Fout,3); C_FIXDIV(Fout[m],3); C_FIXDIV(Fout[m2],3);
C_MUL(scratch[1],Fout[m] , *tw1);
C_MUL(scratch[2],Fout[m2] , *tw2);
C_ADD(scratch[3],scratch[1],scratch[2]);
C_SUB(scratch[0],scratch[1],scratch[2]);
tw1 += fstride;
tw2 += fstride*2;
Fout[m].r = Fout->r - HALF_OF(scratch[3].r);
Fout[m].i = Fout->i - HALF_OF(scratch[3].i);
C_MULBYSCALAR( scratch[0] , epi3.i );
C_ADDTO(*Fout,scratch[3]);
Fout[m2].r = Fout[m].r + scratch[0].i;
Fout[m2].i = Fout[m].i - scratch[0].r;
Fout[m].r -= scratch[0].i;
Fout[m].i += scratch[0].r;
++Fout;
}while(--k);
}
static void kf_bfly5(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
int m
)
{
kiss_fft_cpx *Fout0,*Fout1,*Fout2,*Fout3,*Fout4;
int u;
kiss_fft_cpx scratch[13];
kiss_fft_cpx * twiddles = st->twiddles;
kiss_fft_cpx *tw;
kiss_fft_cpx ya,yb;
ya = twiddles[fstride*m];
yb = twiddles[fstride*2*m];
Fout0=Fout;
Fout1=Fout0+m;
Fout2=Fout0+2*m;
Fout3=Fout0+3*m;
Fout4=Fout0+4*m;
tw=st->twiddles;
for ( u=0; u<m; ++u ) {
C_FIXDIV( *Fout0,5); C_FIXDIV( *Fout1,5); C_FIXDIV( *Fout2,5); C_FIXDIV( *Fout3,5); C_FIXDIV( *Fout4,5);
scratch[0] = *Fout0;
C_MUL(scratch[1] ,*Fout1, tw[u*fstride]);
C_MUL(scratch[2] ,*Fout2, tw[2*u*fstride]);
C_MUL(scratch[3] ,*Fout3, tw[3*u*fstride]);
C_MUL(scratch[4] ,*Fout4, tw[4*u*fstride]);
C_ADD( scratch[7],scratch[1],scratch[4]);
C_SUB( scratch[10],scratch[1],scratch[4]);
C_ADD( scratch[8],scratch[2],scratch[3]);
C_SUB( scratch[9],scratch[2],scratch[3]);
Fout0->r += scratch[7].r + scratch[8].r;
Fout0->i += scratch[7].i + scratch[8].i;
scratch[5].r = scratch[0].r + S_MUL(scratch[7].r,ya.r) + S_MUL(scratch[8].r,yb.r);
scratch[5].i = scratch[0].i + S_MUL(scratch[7].i,ya.r) + S_MUL(scratch[8].i,yb.r);
scratch[6].r = S_MUL(scratch[10].i,ya.i) + S_MUL(scratch[9].i,yb.i);
scratch[6].i = -S_MUL(scratch[10].r,ya.i) - S_MUL(scratch[9].r,yb.i);
C_SUB(*Fout1,scratch[5],scratch[6]);
C_ADD(*Fout4,scratch[5],scratch[6]);
scratch[11].r = scratch[0].r + S_MUL(scratch[7].r,yb.r) + S_MUL(scratch[8].r,ya.r);
scratch[11].i = scratch[0].i + S_MUL(scratch[7].i,yb.r) + S_MUL(scratch[8].i,ya.r);
scratch[12].r = - S_MUL(scratch[10].i,yb.i) + S_MUL(scratch[9].i,ya.i);
scratch[12].i = S_MUL(scratch[10].r,yb.i) - S_MUL(scratch[9].r,ya.i);
C_ADD(*Fout2,scratch[11],scratch[12]);
C_SUB(*Fout3,scratch[11],scratch[12]);
++Fout0;++Fout1;++Fout2;++Fout3;++Fout4;
}
}
/* perform the butterfly for one stage of a mixed radix FFT */
static void kf_bfly_generic(
kiss_fft_cpx * Fout,
const size_t fstride,
const kiss_fft_cfg st,
int m,
int p
)
{
int u,k,q1,q;
kiss_fft_cpx * twiddles = st->twiddles;
kiss_fft_cpx t;
int Norig = st->nfft;
kiss_fft_cpx * scratch = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC(sizeof(kiss_fft_cpx)*p);
for ( u=0; u<m; ++u ) {
k=u;
for ( q1=0 ; q1<p ; ++q1 ) {
scratch[q1] = Fout[ k ];
C_FIXDIV(scratch[q1],p);
k += m;
}
k=u;
for ( q1=0 ; q1<p ; ++q1 ) {
int twidx=0;
Fout[ k ] = scratch[0];
for (q=1;q<p;++q ) {
twidx += fstride * k;
if (twidx>=Norig) twidx-=Norig;
C_MUL(t,scratch[q] , twiddles[twidx] );
C_ADDTO( Fout[ k ] ,t);
}
k += m;
}
}
KISS_FFT_TMP_FREE(scratch);
}
static
void kf_work(
kiss_fft_cpx * Fout,
const kiss_fft_cpx * f,
const size_t fstride,
int in_stride,
int * factors,
const kiss_fft_cfg st
)
{
kiss_fft_cpx * Fout_beg=Fout;
const int p=*factors++; /* the radix */
const int m=*factors++; /* stage's fft length/p */
const kiss_fft_cpx * Fout_end = Fout + p*m;
#ifdef _OPENMP
// use openmp extensions at the
// top-level (not recursive)
if (fstride==1 && p<=5)
{
int k;
// execute the p different work units in different threads
# pragma omp parallel for
for (k=0;k<p;++k)
kf_work( Fout +k*m, f+ fstride*in_stride*k,fstride*p,in_stride,factors,st);
// all threads have joined by this point
switch (p) {
case 2: kf_bfly2(Fout,fstride,st,m); break;
case 3: kf_bfly3(Fout,fstride,st,m); break;
case 4: kf_bfly4(Fout,fstride,st,m); break;
case 5: kf_bfly5(Fout,fstride,st,m); break;
default: kf_bfly_generic(Fout,fstride,st,m,p); break;
}
return;
}
#endif
if (m==1) {
do{
*Fout = *f;
f += fstride*in_stride;
}while(++Fout != Fout_end );
}else{
do{
// recursive call:
// DFT of size m*p performed by doing
// p instances of smaller DFTs of size m,
// each one takes a decimated version of the input
kf_work( Fout , f, fstride*p, in_stride, factors,st);
f += fstride*in_stride;
}while( (Fout += m) != Fout_end );
}
Fout=Fout_beg;
// recombine the p smaller DFTs
switch (p) {
case 2: kf_bfly2(Fout,fstride,st,m); break;
case 3: kf_bfly3(Fout,fstride,st,m); break;
case 4: kf_bfly4(Fout,fstride,st,m); break;
case 5: kf_bfly5(Fout,fstride,st,m); break;
default: kf_bfly_generic(Fout,fstride,st,m,p); break;
}
}
/* facbuf is populated by p1,m1,p2,m2, ...
where
p[i] * m[i] = m[i-1]
m0 = n */
static
void kf_factor(int n,int * facbuf)
{
int p=4;
double floor_sqrt;
floor_sqrt = floor( sqrt((double)n) );
/*factor out powers of 4, powers of 2, then any remaining primes */
do {
while (n % p) {
switch (p) {
case 4: p = 2; break;
case 2: p = 3; break;
default: p += 2; break;
}
if (p > floor_sqrt)
p = n; /* no more factors, skip to end */
}
n /= p;
*facbuf++ = p;
*facbuf++ = n;
} while (n > 1);
}
/*
*
* User-callable function to allocate all necessary storage space for the fft.
*
* The return value is a contiguous block of memory, allocated with malloc. As such,
* It can be freed with free(), rather than a kiss_fft-specific function.
* */
kiss_fft_cfg kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem )
{
kiss_fft_cfg st=NULL;
size_t memneeded = sizeof(struct kiss_fft_state)
+ sizeof(kiss_fft_cpx)*(nfft-1); /* twiddle factors*/
if ( lenmem==NULL ) {
st = ( kiss_fft_cfg)KISS_FFT_MALLOC( memneeded );
}else{
if (mem != NULL && *lenmem >= memneeded)
st = (kiss_fft_cfg)mem;
*lenmem = memneeded;
}
if (st) {
int i;
st->nfft=nfft;
st->inverse = inverse_fft;
for (i=0;i<nfft;++i) {
const double pi=3.141592653589793238462643383279502884197169399375105820974944;
double phase = -2*pi*i / nfft;
if (st->inverse)
phase *= -1;
kf_cexp(st->twiddles+i, phase );
}
kf_factor(nfft,st->factors);
}
return st;
}
void kiss_fft_stride(kiss_fft_cfg st,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int in_stride)
{
if (fin == fout) {
//NOTE: this is not really an in-place FFT algorithm.
//It just performs an out-of-place FFT into a temp buffer
kiss_fft_cpx * tmpbuf = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC( sizeof(kiss_fft_cpx)*st->nfft);
kf_work(tmpbuf,fin,1,in_stride, st->factors,st);
memcpy(fout,tmpbuf,sizeof(kiss_fft_cpx)*st->nfft);
KISS_FFT_TMP_FREE(tmpbuf);
}else{
kf_work( fout, fin, 1,in_stride, st->factors,st );
}
}
void kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout)
{
kiss_fft_stride(cfg,fin,fout,1);
}
void kiss_fft_cleanup(void)
{
// nothing needed any more
}
int kiss_fft_next_fast_size(int n)
{
while(1) {
int m=n;
while ( (m%2) == 0 ) m/=2;
while ( (m%3) == 0 ) m/=3;
while ( (m%5) == 0 ) m/=5;
if (m<=1)
break; /* n is completely factorable by twos, threes, and fives */
n++;
}
return n;
}
+132
View File
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2003-2010, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#ifndef KISS_FFT_H
#define KISS_FFT_H
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <string.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
ATTENTION!
If you would like a :
-- a utility that will handle the caching of fft objects
-- real-only (no imaginary time component ) FFT
-- a multi-dimensional FFT
-- a command-line utility to perform ffts
-- a command-line utility to perform fast-convolution filtering
Then see kfc.h kiss_fftr.h kiss_fftnd.h fftutil.c kiss_fastfir.c
in the tools/ directory.
*/
#ifdef USE_SIMD
# include <xmmintrin.h>
# define kiss_fft_scalar __m128
#define KISS_FFT_MALLOC(nbytes) _mm_malloc(nbytes,16)
#define KISS_FFT_FREE _mm_free
#else
#define KISS_FFT_MALLOC malloc
#define KISS_FFT_FREE free
#endif
#ifdef FIXED_POINT
#include <sys/types.h>
# if (FIXED_POINT == 32)
# define kiss_fft_scalar int32_t
# else
# define kiss_fft_scalar int16_t
# endif
#else
# ifndef kiss_fft_scalar
/* default is float */
# define kiss_fft_scalar float
# endif
#endif
typedef struct {
kiss_fft_scalar r;
kiss_fft_scalar i;
}kiss_fft_cpx;
typedef struct kiss_fft_state* kiss_fft_cfg;
/*
* kiss_fft_alloc
*
* Initialize a FFT (or IFFT) algorithm's cfg/state buffer.
*
* typical usage: kiss_fft_cfg mycfg=kiss_fft_alloc(1024,0,NULL,NULL);
*
* The return value from fft_alloc is a cfg buffer used internally
* by the fft routine or NULL.
*
* If lenmem is NULL, then kiss_fft_alloc will allocate a cfg buffer using malloc.
* The returned value should be free()d when done to avoid memory leaks.
*
* The state can be placed in a user supplied buffer 'mem':
* If lenmem is not NULL and mem is not NULL and *lenmem is large enough,
* then the function places the cfg in mem and the size used in *lenmem
* and returns mem.
*
* If lenmem is not NULL and ( mem is NULL or *lenmem is not large enough),
* then the function returns NULL and places the minimum cfg
* buffer size in *lenmem.
* */
kiss_fft_cfg kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem);
/*
* kiss_fft(cfg,in_out_buf)
*
* Perform an FFT on a complex input buffer.
* for a forward FFT,
* fin should be f[0] , f[1] , ... ,f[nfft-1]
* fout will be F[0] , F[1] , ... ,F[nfft-1]
* Note that each element is complex and can be accessed like
f[k].r and f[k].i
* */
void kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout);
/*
A more generic version of the above function. It reads its input from every Nth sample.
* */
void kiss_fft_stride(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int fin_stride);
/* If kiss_fft_alloc allocated a buffer, it is one contiguous
buffer and can be simply free()d when no longer needed*/
#define kiss_fft_free KISS_FFT_FREE
/*
Cleans up some memory that gets managed internally. Not necessary to call, but it might clean up
your compiler output to call this before you exit.
*/
void kiss_fft_cleanup(void);
/*
* Returns the smallest integer k, such that k>=n and k has only "fast" factors (2,3,5)
*/
int kiss_fft_next_fast_size(int n);
/* for real ffts, we need an even size */
#define kiss_fftr_next_fast_size_real(n) \
(kiss_fft_next_fast_size( ((n)+1)>>1)<<1)
#ifdef __cplusplus
}
#endif
#endif
+153
View File
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2003-2004, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#include "kiss_fftr.h"
#include "_kiss_fft_guts.h"
struct kiss_fftr_state{
kiss_fft_cfg substate;
kiss_fft_cpx * tmpbuf;
kiss_fft_cpx * super_twiddles;
#ifdef USE_SIMD
void * pad;
#endif
};
kiss_fftr_cfg kiss_fftr_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem)
{
int i;
kiss_fftr_cfg st = NULL;
size_t subsize = 0, memneeded;
if (nfft & 1) {
fprintf(stderr,"Real FFT optimization must be even.\n");
return NULL;
}
nfft >>= 1;
kiss_fft_alloc (nfft, inverse_fft, NULL, &subsize);
memneeded = sizeof(struct kiss_fftr_state) + subsize + sizeof(kiss_fft_cpx) * ( nfft * 3 / 2);
if (lenmem == NULL) {
st = (kiss_fftr_cfg) KISS_FFT_MALLOC (memneeded);
} else {
if (*lenmem >= memneeded)
st = (kiss_fftr_cfg) mem;
*lenmem = memneeded;
}
if (!st)
return NULL;
st->substate = (kiss_fft_cfg) (st + 1); /*just beyond kiss_fftr_state struct */
st->tmpbuf = (kiss_fft_cpx *) (((char *) st->substate) + subsize);
st->super_twiddles = st->tmpbuf + nfft;
kiss_fft_alloc(nfft, inverse_fft, st->substate, &subsize);
for (i = 0; i < nfft/2; ++i) {
double phase =
-3.14159265358979323846264338327 * ((double) (i+1) / nfft + .5);
if (inverse_fft)
phase *= -1;
kf_cexp (st->super_twiddles+i,phase);
}
return st;
}
void kiss_fftr(kiss_fftr_cfg st,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata)
{
/* input buffer timedata is stored row-wise */
int k,ncfft;
kiss_fft_cpx fpnk,fpk,f1k,f2k,tw,tdc;
if ( st->substate->inverse) {
fprintf(stderr,"kiss fft usage error: improper alloc\n");
exit(1);
}
ncfft = st->substate->nfft;
/*perform the parallel fft of two real signals packed in real,imag*/
kiss_fft( st->substate , (const kiss_fft_cpx*)timedata, st->tmpbuf );
/* The real part of the DC element of the frequency spectrum in st->tmpbuf
* contains the sum of the even-numbered elements of the input time sequence
* The imag part is the sum of the odd-numbered elements
*
* The sum of tdc.r and tdc.i is the sum of the input time sequence.
* yielding DC of input time sequence
* The difference of tdc.r - tdc.i is the sum of the input (dot product) [1,-1,1,-1...
* yielding Nyquist bin of input time sequence
*/
tdc.r = st->tmpbuf[0].r;
tdc.i = st->tmpbuf[0].i;
C_FIXDIV(tdc,2);
CHECK_OVERFLOW_OP(tdc.r ,+, tdc.i);
CHECK_OVERFLOW_OP(tdc.r ,-, tdc.i);
freqdata[0].r = tdc.r + tdc.i;
freqdata[ncfft].r = tdc.r - tdc.i;
#ifdef USE_SIMD
freqdata[ncfft].i = freqdata[0].i = _mm_set1_ps(0);
#else
freqdata[ncfft].i = freqdata[0].i = 0;
#endif
for ( k=1;k <= ncfft/2 ; ++k ) {
fpk = st->tmpbuf[k];
fpnk.r = st->tmpbuf[ncfft-k].r;
fpnk.i = - st->tmpbuf[ncfft-k].i;
C_FIXDIV(fpk,2);
C_FIXDIV(fpnk,2);
C_ADD( f1k, fpk , fpnk );
C_SUB( f2k, fpk , fpnk );
C_MUL( tw , f2k , st->super_twiddles[k-1]);
freqdata[k].r = HALF_OF(f1k.r + tw.r);
freqdata[k].i = HALF_OF(f1k.i + tw.i);
freqdata[ncfft-k].r = HALF_OF(f1k.r - tw.r);
freqdata[ncfft-k].i = HALF_OF(tw.i - f1k.i);
}
}
void kiss_fftri(kiss_fftr_cfg st,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata)
{
/* input buffer timedata is stored row-wise */
int k, ncfft;
if (st->substate->inverse == 0) {
fprintf (stderr, "kiss fft usage error: improper alloc\n");
exit (1);
}
ncfft = st->substate->nfft;
st->tmpbuf[0].r = freqdata[0].r + freqdata[ncfft].r;
st->tmpbuf[0].i = freqdata[0].r - freqdata[ncfft].r;
C_FIXDIV(st->tmpbuf[0],2);
for (k = 1; k <= ncfft / 2; ++k) {
kiss_fft_cpx fk, fnkc, fek, fok, tmp;
fk = freqdata[k];
fnkc.r = freqdata[ncfft - k].r;
fnkc.i = -freqdata[ncfft - k].i;
C_FIXDIV( fk , 2 );
C_FIXDIV( fnkc , 2 );
C_ADD (fek, fk, fnkc);
C_SUB (tmp, fk, fnkc);
C_MUL (fok, tmp, st->super_twiddles[k-1]);
C_ADD (st->tmpbuf[k], fek, fok);
C_SUB (st->tmpbuf[ncfft - k], fek, fok);
#ifdef USE_SIMD
st->tmpbuf[ncfft - k].i *= _mm_set1_ps(-1.0);
#else
st->tmpbuf[ncfft - k].i *= -1;
#endif
}
kiss_fft (st->substate, st->tmpbuf, (kiss_fft_cpx *) timedata);
}
+54
View File
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2003-2004, Mark Borgerding. All rights reserved.
* This file is part of KISS FFT - https://github.com/mborgerding/kissfft
*
* SPDX-License-Identifier: BSD-3-Clause
* See COPYING file for more information.
*/
#ifndef KISS_FTR_H
#define KISS_FTR_H
#include "kiss_fft.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
Real optimized version can save about 45% cpu time vs. complex fft of a real seq.
*/
typedef struct kiss_fftr_state *kiss_fftr_cfg;
kiss_fftr_cfg kiss_fftr_alloc(int nfft,int inverse_fft,void * mem, size_t * lenmem);
/*
nfft must be even
If you don't care to allocate space, use mem = lenmem = NULL
*/
void kiss_fftr(kiss_fftr_cfg cfg,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata);
/*
input timedata has nfft scalar points
output freqdata has nfft/2+1 complex points
*/
void kiss_fftri(kiss_fftr_cfg cfg,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata);
/*
input freqdata has nfft/2+1 complex points
output timedata has nfft scalar points
*/
#define kiss_fftr_free KISS_FFT_FREE
#ifdef __cplusplus
}
#endif
#endif
+54
View File
@@ -0,0 +1,54 @@
#
# On MS Windows using Msys/MinGW gfortran invoke like this:
#
# FC=gfortran make
#
# On macOS using MacPorts gfortran invoke like this:
#
# FC=gfortran make
#
# or if the gfortran compiler is named gfortran-mp-8 or similar
#
# FC=gfortran-mp-8 make
#
# otherwise invoke like this:
#
# make
#
ifeq ($(OS),Windows_NT)
EXE = .exe
endif
EXES = hashcodes$(EXE) std_call_to_c28$(EXE) nonstd_to_c58$(EXE) \
free_text_to_f71$(EXE) grid4_to_g15$(EXE) grid6_to_g25$(EXE) \
gen_crc14$(EXE)
%.o: %.f90
$(FC) -c $(FFLAGS) -o $@ $<
all: $(EXES)
hashcodes$(EXE): hashcodes.o
${FC} -o $@ $^
std_call_to_c28$(EXE): std_call_to_c28.o
${FC} -o $@ $^
nonstd_to_c58$(EXE): nonstd_to_c58.o
${FC} -o $@ $^
free_text_to_f71$(EXE): free_text_to_f71.o
${FC} -o $@ $^
grid4_to_g15$(EXE): grid4_to_g15.o
${FC} -o $@ $^
grid6_to_g25$(EXE): grid6_to_g25.o
${FC} -o $@ $^
gen_crc14$(EXE): gen_crc14.o
${FC} -o $@ $^
clean:
-rm $(EXES) *.o
+13
View File
@@ -0,0 +1,13 @@
! Abbreviations for ARRL/RAC Sections as a Fortran 90 data statement:
data csec/ &
"AB ","AK ","AL ","AR ","AZ ","BC ","CO ","CT ","DE ","EB ", &
"EMA","ENY","EPA","EWA","GA ","GTA","IA ","ID ","IL ","IN ", &
"KS ","KY ","LA ","LAX","MAR","MB ","MDC","ME ","MI ","MN ", &
"MO ","MS ","MT ","NC ","ND ","NE ","NFL","NH ","NL ","NLI", &
"NM ","NNJ","NNY","NT ","NTX","NV ","OH ","OK ","ONE","ONN", &
"ONS","OR ","ORG","PAC","PR ","QC ","RI ","SB ","SC ","SCV", &
"SD ","SDG","SF ","SFL","SJV","SK ","SNJ","STX","SV ","TN ", &
"UT ","VA ","VI ","VT ","WCF","WI ","WMA","WNY","WPA","WTX", &
"WV ","WWA","WY ","DX "/
+67
View File
@@ -0,0 +1,67 @@
program free_text_to_f71
character*13 c13,w
character*71 f71
character*42 c
character*1 qa(10),qb(10)
data c/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?'/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: free_text_to_f71 "<message>"'
print*,'Example: free_text_to_f71 "TNX BOB 73 GL"'
go to 999
endif
call getarg(1,c13)
call mp_short_init
qa=char(0)
w=adjustr(c13)
do i=1,13
j=index(c,w(i:i))-1
if(j.lt.0) j=0
call mp_short_mult(qb,qa(2:10),9,42) !qb(1:9)=42*qa(2:9)
call mp_short_add(qa,qb(2:10),9,j) !qa(1:9)=qb(2:9)+j
enddo
write(f71,1000) qa(2:10)
1000 format(b7.7,8b8.8)
write(*,1010) c13,f71
1010 format('Free text: ',a13/'f71: ',a71)
999 end program free_text_to_f71
subroutine mp_short_ops(w,u)
! Multi-precision arithmetic with storage in character arrays.
character*1 w(*),u(*)
integer i,ireg,j,n,ir,iv,ii1,ii2
character*1 creg(4)
save ii1,ii2
equivalence (ireg,creg)
entry mp_short_init
ireg=256*ichar('2')+ichar('1')
do j=1,4
if (creg(j).eq.'1') ii1=j
if (creg(j).eq.'2') ii2=j
enddo
return
entry mp_short_add(w,u,n,iv)
ireg=256*iv
do j=n,1,-1
ireg=ichar(u(j))+ichar(creg(ii2))
w(j+1)=creg(ii1)
enddo
w(1)=creg(ii2)
return
entry mp_short_mult(w,u,n,iv)
ireg=0
do j=n,1,-1
ireg=ichar(u(j))*iv+ichar(creg(ii2))
w(j+1)=creg(ii1)
enddo
w(1)=creg(ii2)
return
return
end subroutine mp_short_ops
+36
View File
@@ -0,0 +1,36 @@
program gen_crc14
character m77*77,c14*14
integer mc(96),r(15),p(15),ncrc
! polynomial for 14-bit CRC 0x6757
data p/1,1,0,0,1,1,1,0,1,0,1,0,1,1,1/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: gen_crc14 <77-bit message>'
print*,'Example: gen_crc14 "00000000000000000000000000100000010011011111110011011100100010100001010000001"'
go to 999
endif
! pad the 77bit message out to 96 bits
call getarg(1,m77)
read(m77,'(77i1)') mc(1:77)
mc(78:96)=0
! divide by polynomial
r=mc(1:15)
do i=0,81
r(15)=mc(i+15)
r=mod(r+r(1)*p,2)
r=cshift(r,1)
enddo
! the crc is in r(1:14) - print it in various ways:
write(c14,'(14b1)') r(1:14)
write(*,'(a40,1x,a14)') 'crc14 as a string: ',c14
read(c14,'(b14.14)') ncrc
write(*,'(a40,i6)') 'crc14 as an integer: ',ncrc
write(*,'(a40,1x,b14.14)') 'binary representation of the integer: ',ncrc
999 end program gen_crc14
+86
View File
@@ -0,0 +1,86 @@
This file contains the generator matrix for the FT8/FT4 (174,91) LDPC code.
The matrix has 91 columns and 83 rows.
1000001100101001110011100001000110111111001100011110101011110101000010011111001001111111110
0111011000011100001001100100111000100101110000100101100100110011010101001001001100010011001
1101110000100110010110010000001011111011001001110111110001100100000100001010000110111101110
0001101100111111010000010111100001011000110011010010110111010011001111101100011111110110001
0000100111111101101001001111111011100000010000011001010111111101000000110100011110000011101
0000011101111100110011001100000100011011100010000111001111101101010111000011110101001000101
0010100110110110001010101111111000111100101000000011011011110100111111100001101010011101101
0110000001010100111110101111010111110011010111011001011011010011101100001100100011000011111
1110001000000111100110001110010000110001000011101110110100100111100010000100101011101001000
0111011101011100100111000000100011101000000011100010011011011101101011100101011000110001100
1011000010111000000100010000001010001100001010111111100110010111001000010011010010000111110
0001100010100000110010010010001100011111110001100000101011011111010111000101111010100011001
0111011001000111000111101000001100000010101000000111001000011110000000011011000100101011100
1111111110111100110010111000000011001010100000110100000111111010111110110100011110110010111
0110011010100111001010100001010110001111100100110010010110100010101111110110011100010111000
1100010000100100001101101000100111111110100001011011000111000101000100110110001110100001100
0000110111111111011100111001010000010100110100011010000110110011010010110001110000100111000
0001010110110100100010000011000001100011011011001000101110011001100010010100100101110010111
0010100110101000100111000000110100111101111010000001110101100110010101001000100110110000111
0100111100010010011011110011011111111010010100011100101111100110000110111101011010111001010
1001100111000100011100100011100111010000110110010111110100111100100001001110000010010100000
0001100100011001101101110101000100011001011101100101011000100001101110110100111100011110100
0000100111011011000100101101011100110001111110101110111000001011100001101101111101101011100
0100100010001111110000110011110111110100001111111011110111101110101001001110101011111011010
1000001001110100001000111110111001000000101101100111010111110111010101101110101101011111111
1010101111100001100101111100010010000100110010110111010001110101011100010100010010101001101
0010101101010000000011100100101111000000111011000101101001101101001010111101101111011101000
1100010001110100101010100101001111010111000000100001100001110110000101100110100100110110000
1000111010111010000110100001001111011011001100111001000010111101011001110001100011001110110
0111010100111000010001000110011100111010001001110111100000101100110001000010000000010010111
0000011011111111100000111010000101000101110000110111000000110101101001011100000100100110100
0011101100110111010000010111100001011000110011000010110111010011001111101100001111110110001
1001101001001010010110100010100011101110000101111100101010011100001100100100100001000010110
1011110000101001111101000110010100110000100111001001011101111110100010010110000100001010010
0010011001100011101011100110110111011111100010110101110011100010101110110010100101001000100
0100011011110010001100011110111111100100010101110000001101001100000110000001010001000001100
0011111110110010110011101000010110101011111010011011000011000111001011100000011011111011111
1101111010000111010010000001111100101000001011000001010100111001011100011010000010100010111
1111110011010111110011001111001000111100011010011111101010011001101110111010000101000001001
1111000000100110000101000100011111101001010010010000110010101000111001000111010011001110110
0100010000010000000100010101100000011000000110010110111110010101110011011101011100000001001
0000100010001111110000110001110111110100101111111011110111100010101001001110101011111011010
1011100011111110111100011011011000110000011101110010100111111011000010100000011110001100000
0101101011111110101001111010110011001100101101110111101110111100100111011001100110101001000
0100100110100111000000010110101011000110010100111111011001011110110011011100100100000111011
0001100101000100110100001000010110111110010011100111110110101000110101101100110001111101000
0010010100011111011000101010110111000100000000110010111100001110111001110001010000000000001
0101011001000111000111111000011100000010101000000111001000011110000000001011000100101011100
0010101110001110010010010010001111110010110111010101000111100010110101010011011111111010000
0110101101010101000010100100000010100110011011110100011101010101110111101001010111000010011
1010000110001010110100101000110101001110001001111111111010010010101001001111011011001000010
0001000011000010111001011000011000111000100011001011100000101010001111011000000001110101100
1110111100110100101001000001100000010111111011100000001000010011001111011011001011101011000
0111111010011100000011000101010000110010010110101001110000010101100000110110111000000000000
0011011010010011111001010111001011010001111111011110010011001101111100000111100111101000011
1011111110110010110011101100010110101011111000011011000011000111001011100000011111111011111
0111111011100001100000100011000011000101100000111100110011001100010101111101010010110000100
1010000001100110110010110010111111101101101011111100100111110101001001100110010000010010011
1011101100100011011100100101101010111100010001111100110001011111010011001100010011001101001
1101111011011001110110111010001110111110111001000000110001011001101101010110000010011011010
1101100110100111000000010110101011000110010100111110011011011110110011011100100100000011011
1001101011010100011010101110110101011111011100000111111100101000000010101011010111111100010
1110010110010010000111000111011110000010001001011000011100110001011011010111110100111100001
0100111100010100110110101000001001000010101010001011100001101101110010100111001100110101001
1000101110001011010100000111101011010100011001111101010001000100000111011111011101110000111
0010001010000011000111001001110011110001000101101001010001100111101011010000010010110110100
0010000100111011100000111000111111100010101011100101010011000011100011101110011100011000000
0101110110010010011010110110110111010111000111110000100001010001100000011010010011100001001
0110011010101011011110011101010010110010100111101110011011100110100101010000100111100101011
1001010110000001010010000110100000101101011101001000101000111000110111010110100010111010101
1011100011001110000000100000110011110000011010011100001100101010011100100011101010110001010
1111010000110011000111010110110101000110000101100000011111101001010101110101001001110100011
0110110110100010001110111010010000100100101110010101100101100001001100111100111110011100100
1010011000110110101111001011110001111011001100001100010111111011111010101110011001111111111
0101110010110000110110000110101000000111110111110110010101001010100100001000100110100010000
1111000100011111000100000110100001001000011110000000111111001001111011001101110110000000101
0001111110111011010100110110010011111011100011010010110010011101011100110000110101011011101
1111110010111000011010111100011100001010010100001100100111010000001010100101110100000011010
1010010100110100010000110011000000101001111010101100000101011111001100100010111000110100110
1100100110001001110110011100011111000011110100111011100011000101010111010111010100010011000
0111101110110011100010110010111100000001100001101101010001100110010000111010111010010110001
0010011001000100111010111010110111101011010001001011100101000110011111010001111101000010110
0110000010001100110010000101011101011001010010111111101110110101010111010110100101100000000
+55
View File
@@ -0,0 +1,55 @@
program grid4_to_g15
parameter (MAXGRID4=32400)
character*4 w,grid4
character c1*1,c2*2
logical is_grid4
is_grid4(grid4)=len(trim(grid4)).eq.4 .and. &
grid4(1:1).ge.'A' .and. grid4(1:1).le.'R' .and. &
grid4(2:2).ge.'A' .and. grid4(2:2).le.'R' .and. &
grid4(3:3).ge.'0' .and. grid4(3:3).le.'9' .and. &
grid4(4:4).ge.'0' .and. grid4(4:4).le.'9'
nargs=iargc()
if(nargs.ne.1) then
print*,'Convert a 4-character grid, signal report, etc., to a g15 value.'
print*,'Usage examples:'
print*,'grid4_to_g15 FN20'
print*,'grid4_to_g15 -11'
print*,'grid4_to_g15 +02'
print*,'grid4_to_g15 RRR'
print*,'grid4_to_g15 RR73'
print*,'grid4_to_g15 73'
print*,'grid4_to_g15 ""'
go to 999
endif
call getarg(1,w)
if(is_grid4(w) .and. w.ne.'RR73') then
j1=(ichar(w(1:1))-ichar('A'))*18*10*10
j2=(ichar(w(2:2))-ichar('A'))*10*10
j3=(ichar(w(3:3))-ichar('0'))*10
j4=(ichar(w(4:4))-ichar('0'))
igrid4=j1+j2+j3+j4
else
c1=w(1:1)
if(c1.ne.'+' .and. c1.ne.'-'.and. trim(w).ne.'RRR' .and. w.ne.'RR73' &
.and. trim(w).ne.'73' .and. len(trim(w)).ne.0) go to 900
if(c1.eq.'+' .or. c1.eq.'-') then
read(w,*,err=900) irpt
irpt=irpt+35
endif
if(len(trim(w)).eq.0) irpt=1
if(trim(w).eq.'RRR') irpt=2
if(w.eq.'RR73') irpt=3
if(trim(w).eq.'73') irpt=4
igrid4=MAXGRID4 + irpt
endif
write(*,1000) w,igrid4,igrid4
1000 format('Encoded word: ',a4,' g15 in binary: ',b15.15,' decimal:',i6)
go to 999
900 write(*,1900)
1900 format('Invalid input')
999 end program grid4_to_g15
+41
View File
@@ -0,0 +1,41 @@
program grid6_to_g25
parameter (MAXGRID4=32400)
character*6 w,grid6
character c1*1,c2*2
logical is_grid6
is_grid6(grid6)=len(trim(grid6)).eq.6 .and. &
grid6(1:1).ge.'A' .and. grid6(1:1).le.'R' .and. &
grid6(2:2).ge.'A' .and. grid6(2:2).le.'R' .and. &
grid6(3:3).ge.'0' .and. grid6(3:3).le.'9' .and. &
grid6(4:4).ge.'0' .and. grid6(4:4).le.'9' .and. &
grid6(5:5).ge.'A' .and. grid6(5:5).le.'X' .and. &
grid6(6:6).ge.'A' .and. grid6(6:6).le.'X'
nargs=iargc()
if(nargs.ne.1) then
print*,'Convert a 6-character grid to a g25 value.'
print*,'Usage: grid6_to_g25 IO91NP'
go to 999
endif
call getarg(1,w)
if(.not. is_grid6(w)) go to 900
j1=(ichar(w(1:1))-ichar('A'))*18*10*10*24*24
j2=(ichar(w(2:2))-ichar('A'))*10*10*24*24
j3=(ichar(w(3:3))-ichar('0'))*10*24*24
j4=(ichar(w(4:4))-ichar('0'))*24*24
j5=(ichar(w(5:5))-ichar('A'))*24
j6=(ichar(w(6:6))-ichar('A'))
igrid6=j1+j2+j3+j4+j5+j6
write(*,1000) w,igrid6,igrid6
1000 format('Encoded word: ',a6,' g25 in binary: ',b25.25/ &
30x,'decimal:',i9)
go to 999
900 write(*,1900)
1900 format('Invalid input')
999 end program grid6_to_g25
+34
View File
@@ -0,0 +1,34 @@
program hashcodes
parameter (NTOKENS=2063592)
integer*8 nprime,n8(3)
integer nbits(3),ihash(3)
character*11 callsign
character*38 c
data c/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/'/
data nprime/47055833459_8/,nbits/10,12,22/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: hashcodes <callsign>'
print*,'Examples: hashcodes PJ4/K1ABC'
print*,' hashcodes YW18FIFA'
go to 999
endif
call getarg(1,callsign)
callsign=adjustl(callsign)
do k=1,3
n8(k)=0
do i=1,11
j=index(c,callsign(i:i)) - 1
n8(k)=38*n8(k) + j
enddo
ihash(k)=ishft(nprime*n8(k),nbits(k)-64)
enddo
ih22_biased=ihash(3) + NTOKENS
write(*,1000) callsign,ihash,ih22_biased
1000 format('Callsign',9x,'h10',7x,'h12',7x,'h22'/41('-')/ &
a11,i9,2i10,/'Biased for storage in c28:',i14)
999 end program hashcodes
+24
View File
@@ -0,0 +1,24 @@
program nonstd_to_c58
integer*8 n58
character*11 callsign
character*38 c
data c/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/'/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: nonstd_to_c58 <callsign>'
print*,'Examples: nonstd_to_c58 PJ4/K1ABC'
print*,' nonstd_to_c58 YW18FIFA'
go to 999
endif
call getarg(1,callsign)
n58=0
do i=1,11
n58=n58*38 + index(c,callsign(i:i)) - 1
enddo
write(*,1000) callsign,n58,n58
1000 format('Callsign: ',a11/'c58 (binary): ' b58.58/'c58 (decimal):',i20)
999 end program nonstd_to_c58
+183
View File
@@ -0,0 +1,183 @@
This file specifies the sparse 83x174 parity-check matrix for the
FT8/FT4 (174,91) LDPC code. Each of the 174 columns contains
exactly 3 ones. The rows contain either 6 or 7 ones.
The matrix is specified by the following list consisting of
174 lines, each of which includes 3 numbers.
Each line corresponds to a column of the parity check matrix.
The three numbers are indices of the rows that contain a one in
the corresponding column. The indices range from 1 through 83.
16 45 73
25 51 62
33 58 78
1 44 45
2 7 61
3 6 54
4 35 48
5 13 21
8 56 79
9 64 69
10 19 66
11 36 60
12 37 58
14 32 43
15 63 80
17 28 77
18 74 83
22 53 81
23 30 34
24 31 40
26 41 76
27 57 70
29 49 65
3 38 78
5 39 82
46 50 73
51 52 74
55 71 72
44 67 72
43 68 78
1 32 59
2 6 71
4 16 54
7 65 67
8 30 42
9 22 31
10 18 76
11 23 82
12 28 61
13 52 79
14 50 51
15 81 83
17 29 60
19 33 64
20 26 73
21 34 40
24 27 77
25 55 58
35 53 66
36 48 68
37 46 75
38 45 47
39 57 69
41 56 62
20 49 53
46 52 63
45 70 75
27 35 80
1 15 30
2 68 80
3 36 51
4 28 51
5 31 56
6 20 37
7 40 82
8 60 69
9 10 49
11 44 57
12 39 59
13 24 55
14 21 65
16 71 78
17 30 76
18 25 80
19 61 83
22 38 77
23 41 50
7 26 58
29 32 81
33 40 73
18 34 48
13 42 64
5 26 43
47 69 72
54 55 70
45 62 68
10 63 67
14 66 72
22 60 74
35 39 79
1 46 64
1 24 66
2 5 70
3 31 65
4 49 58
1 4 5
6 60 67
7 32 75
8 48 82
9 35 41
10 39 62
11 14 61
12 71 74
13 23 78
11 35 55
15 16 79
7 9 16
17 54 63
18 50 57
19 30 47
20 64 80
21 28 69
22 25 43
13 22 37
2 47 51
23 54 74
26 34 72
27 36 37
21 36 63
29 40 44
19 26 57
3 46 82
14 15 58
33 52 53
30 43 52
6 9 52
27 33 65
25 69 73
38 55 83
20 39 77
18 29 56
32 48 71
42 51 59
28 44 79
34 60 62
31 45 61
46 68 77
6 24 76
8 10 78
40 41 70
17 50 53
42 66 68
4 22 72
36 64 81
13 29 47
2 8 81
56 67 73
5 38 50
12 38 64
59 72 80
3 26 79
45 76 81
1 65 74
7 18 77
11 56 59
14 39 54
16 37 66
10 28 55
15 60 70
17 25 82
20 30 31
12 67 68
23 75 80
27 32 62
24 69 75
19 21 71
34 53 61
35 46 47
33 59 76
40 43 83
41 42 63
49 75 83
20 44 48
42 49 57
+11
View File
@@ -0,0 +1,11 @@
! Abbreviations for US States and Canadian Provinces as a Fortran 90
! data statement:
data cmult/ &
"AL ","AK ","AZ ","AR ","CA ","CO ","CT ","DE ","FL ","GA ", &
"HI ","ID ","IL ","IN ","IA ","KS ","KY ","LA ","ME ","MD ", &
"MA ","MI ","MN ","MS ","MO ","MT ","NE ","NV ","NH ","NJ ", &
"NM ","NY ","NC ","ND ","OH ","OK ","OR ","PA ","RI ","SC ", &
"SD ","TN ","TX ","UT ","VT ","VA ","WA ","WV ","WI ","WY ", &
"NB ","NS ","QC ","ON ","MB ","SK ","AB ","BC ","NWT","NF ", &
"LB ","NU ","YT ","PEI","DC "/
+31
View File
@@ -0,0 +1,31 @@
program std_call_to_c28
parameter (NTOKENS=2063592,MAX22=4194304)
character*6 call_std
character a1*37,a2*36,a3*10,a4*27
data a1/' 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'/
data a2/'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'/
data a3/'0123456789'/
data a4/' ABCDEFGHIJKLMNOPQRSTUVWXYZ'/
nargs=iargc()
if(nargs.ne.1) then
print*,'Usage: std_call_to_c28 <call_std>'
print*,'Example: std_call_to_c28 K1ABC'
go to 999
endif
call getarg(1,call_std)
call_std=adjustr(call_std)
i1=index(a1,call_std(1:1))-1
i2=index(a2,call_std(2:2))-1
i3=index(a3,call_std(3:3))-1
i4=index(a4,call_std(4:4))-1
i5=index(a4,call_std(5:5))-1
i6=index(a4,call_std(6:6))-1
n28=NTOKENS + MAX22 + 36*10*27*27*27*i1 + 10*27*27*27*i2 + &
27*27*27*i3 + 27*27*i4 + 27*i5 + i6
write(*,1000) call_std,n28
1000 format('Callsign: ',a6,2x,'c28 as decimal integer:',i10)
999 end program std_call_to_c28
+392
View File
@@ -0,0 +1,392 @@
#include "constants.h"
// Costas sync tone pattern
const uint8_t kFT8_Costas_pattern[7] = { 3, 1, 4, 0, 6, 5, 2 };
const uint8_t kFT4_Costas_pattern[4][4] = {
{ 0, 1, 3, 2 },
{ 1, 0, 2, 3 },
{ 2, 3, 1, 0 },
{ 3, 2, 0, 1 }
};
// Gray code map (FTx bits -> channel symbols)
const uint8_t kFT8_Gray_map[8] = { 0, 1, 3, 2, 5, 6, 4, 7 };
const uint8_t kFT4_Gray_map[4] = { 0, 1, 3, 2 };
const uint8_t kFT4_XOR_sequence[10] = {
0x4Au, // 01001010
0x5Eu, // 01011110
0x89u, // 10001001
0xB4u, // 10110100
0xB0u, // 10110000
0x8Au, // 10001010
0x79u, // 01111001
0x55u, // 01010101
0xBEu, // 10111110
0x28u, // 00101 [000]
};
// Parity generator matrix for (174,91) LDPC code, stored in bitpacked format (MSB first)
const uint8_t kFTX_LDPC_generator[FTX_LDPC_M][FTX_LDPC_K_BYTES] = {
{ 0x83, 0x29, 0xce, 0x11, 0xbf, 0x31, 0xea, 0xf5, 0x09, 0xf2, 0x7f, 0xc0 },
{ 0x76, 0x1c, 0x26, 0x4e, 0x25, 0xc2, 0x59, 0x33, 0x54, 0x93, 0x13, 0x20 },
{ 0xdc, 0x26, 0x59, 0x02, 0xfb, 0x27, 0x7c, 0x64, 0x10, 0xa1, 0xbd, 0xc0 },
{ 0x1b, 0x3f, 0x41, 0x78, 0x58, 0xcd, 0x2d, 0xd3, 0x3e, 0xc7, 0xf6, 0x20 },
{ 0x09, 0xfd, 0xa4, 0xfe, 0xe0, 0x41, 0x95, 0xfd, 0x03, 0x47, 0x83, 0xa0 },
{ 0x07, 0x7c, 0xcc, 0xc1, 0x1b, 0x88, 0x73, 0xed, 0x5c, 0x3d, 0x48, 0xa0 },
{ 0x29, 0xb6, 0x2a, 0xfe, 0x3c, 0xa0, 0x36, 0xf4, 0xfe, 0x1a, 0x9d, 0xa0 },
{ 0x60, 0x54, 0xfa, 0xf5, 0xf3, 0x5d, 0x96, 0xd3, 0xb0, 0xc8, 0xc3, 0xe0 },
{ 0xe2, 0x07, 0x98, 0xe4, 0x31, 0x0e, 0xed, 0x27, 0x88, 0x4a, 0xe9, 0x00 },
{ 0x77, 0x5c, 0x9c, 0x08, 0xe8, 0x0e, 0x26, 0xdd, 0xae, 0x56, 0x31, 0x80 },
{ 0xb0, 0xb8, 0x11, 0x02, 0x8c, 0x2b, 0xf9, 0x97, 0x21, 0x34, 0x87, 0xc0 },
{ 0x18, 0xa0, 0xc9, 0x23, 0x1f, 0xc6, 0x0a, 0xdf, 0x5c, 0x5e, 0xa3, 0x20 },
{ 0x76, 0x47, 0x1e, 0x83, 0x02, 0xa0, 0x72, 0x1e, 0x01, 0xb1, 0x2b, 0x80 },
{ 0xff, 0xbc, 0xcb, 0x80, 0xca, 0x83, 0x41, 0xfa, 0xfb, 0x47, 0xb2, 0xe0 },
{ 0x66, 0xa7, 0x2a, 0x15, 0x8f, 0x93, 0x25, 0xa2, 0xbf, 0x67, 0x17, 0x00 },
{ 0xc4, 0x24, 0x36, 0x89, 0xfe, 0x85, 0xb1, 0xc5, 0x13, 0x63, 0xa1, 0x80 },
{ 0x0d, 0xff, 0x73, 0x94, 0x14, 0xd1, 0xa1, 0xb3, 0x4b, 0x1c, 0x27, 0x00 },
{ 0x15, 0xb4, 0x88, 0x30, 0x63, 0x6c, 0x8b, 0x99, 0x89, 0x49, 0x72, 0xe0 },
{ 0x29, 0xa8, 0x9c, 0x0d, 0x3d, 0xe8, 0x1d, 0x66, 0x54, 0x89, 0xb0, 0xe0 },
{ 0x4f, 0x12, 0x6f, 0x37, 0xfa, 0x51, 0xcb, 0xe6, 0x1b, 0xd6, 0xb9, 0x40 },
{ 0x99, 0xc4, 0x72, 0x39, 0xd0, 0xd9, 0x7d, 0x3c, 0x84, 0xe0, 0x94, 0x00 },
{ 0x19, 0x19, 0xb7, 0x51, 0x19, 0x76, 0x56, 0x21, 0xbb, 0x4f, 0x1e, 0x80 },
{ 0x09, 0xdb, 0x12, 0xd7, 0x31, 0xfa, 0xee, 0x0b, 0x86, 0xdf, 0x6b, 0x80 },
{ 0x48, 0x8f, 0xc3, 0x3d, 0xf4, 0x3f, 0xbd, 0xee, 0xa4, 0xea, 0xfb, 0x40 },
{ 0x82, 0x74, 0x23, 0xee, 0x40, 0xb6, 0x75, 0xf7, 0x56, 0xeb, 0x5f, 0xe0 },
{ 0xab, 0xe1, 0x97, 0xc4, 0x84, 0xcb, 0x74, 0x75, 0x71, 0x44, 0xa9, 0xa0 },
{ 0x2b, 0x50, 0x0e, 0x4b, 0xc0, 0xec, 0x5a, 0x6d, 0x2b, 0xdb, 0xdd, 0x00 },
{ 0xc4, 0x74, 0xaa, 0x53, 0xd7, 0x02, 0x18, 0x76, 0x16, 0x69, 0x36, 0x00 },
{ 0x8e, 0xba, 0x1a, 0x13, 0xdb, 0x33, 0x90, 0xbd, 0x67, 0x18, 0xce, 0xc0 },
{ 0x75, 0x38, 0x44, 0x67, 0x3a, 0x27, 0x78, 0x2c, 0xc4, 0x20, 0x12, 0xe0 },
{ 0x06, 0xff, 0x83, 0xa1, 0x45, 0xc3, 0x70, 0x35, 0xa5, 0xc1, 0x26, 0x80 },
{ 0x3b, 0x37, 0x41, 0x78, 0x58, 0xcc, 0x2d, 0xd3, 0x3e, 0xc3, 0xf6, 0x20 },
{ 0x9a, 0x4a, 0x5a, 0x28, 0xee, 0x17, 0xca, 0x9c, 0x32, 0x48, 0x42, 0xc0 },
{ 0xbc, 0x29, 0xf4, 0x65, 0x30, 0x9c, 0x97, 0x7e, 0x89, 0x61, 0x0a, 0x40 },
{ 0x26, 0x63, 0xae, 0x6d, 0xdf, 0x8b, 0x5c, 0xe2, 0xbb, 0x29, 0x48, 0x80 },
{ 0x46, 0xf2, 0x31, 0xef, 0xe4, 0x57, 0x03, 0x4c, 0x18, 0x14, 0x41, 0x80 },
{ 0x3f, 0xb2, 0xce, 0x85, 0xab, 0xe9, 0xb0, 0xc7, 0x2e, 0x06, 0xfb, 0xe0 },
{ 0xde, 0x87, 0x48, 0x1f, 0x28, 0x2c, 0x15, 0x39, 0x71, 0xa0, 0xa2, 0xe0 },
{ 0xfc, 0xd7, 0xcc, 0xf2, 0x3c, 0x69, 0xfa, 0x99, 0xbb, 0xa1, 0x41, 0x20 },
{ 0xf0, 0x26, 0x14, 0x47, 0xe9, 0x49, 0x0c, 0xa8, 0xe4, 0x74, 0xce, 0xc0 },
{ 0x44, 0x10, 0x11, 0x58, 0x18, 0x19, 0x6f, 0x95, 0xcd, 0xd7, 0x01, 0x20 },
{ 0x08, 0x8f, 0xc3, 0x1d, 0xf4, 0xbf, 0xbd, 0xe2, 0xa4, 0xea, 0xfb, 0x40 },
{ 0xb8, 0xfe, 0xf1, 0xb6, 0x30, 0x77, 0x29, 0xfb, 0x0a, 0x07, 0x8c, 0x00 },
{ 0x5a, 0xfe, 0xa7, 0xac, 0xcc, 0xb7, 0x7b, 0xbc, 0x9d, 0x99, 0xa9, 0x00 },
{ 0x49, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xf6, 0x5e, 0xcd, 0xc9, 0x07, 0x60 },
{ 0x19, 0x44, 0xd0, 0x85, 0xbe, 0x4e, 0x7d, 0xa8, 0xd6, 0xcc, 0x7d, 0x00 },
{ 0x25, 0x1f, 0x62, 0xad, 0xc4, 0x03, 0x2f, 0x0e, 0xe7, 0x14, 0x00, 0x20 },
{ 0x56, 0x47, 0x1f, 0x87, 0x02, 0xa0, 0x72, 0x1e, 0x00, 0xb1, 0x2b, 0x80 },
{ 0x2b, 0x8e, 0x49, 0x23, 0xf2, 0xdd, 0x51, 0xe2, 0xd5, 0x37, 0xfa, 0x00 },
{ 0x6b, 0x55, 0x0a, 0x40, 0xa6, 0x6f, 0x47, 0x55, 0xde, 0x95, 0xc2, 0x60 },
{ 0xa1, 0x8a, 0xd2, 0x8d, 0x4e, 0x27, 0xfe, 0x92, 0xa4, 0xf6, 0xc8, 0x40 },
{ 0x10, 0xc2, 0xe5, 0x86, 0x38, 0x8c, 0xb8, 0x2a, 0x3d, 0x80, 0x75, 0x80 },
{ 0xef, 0x34, 0xa4, 0x18, 0x17, 0xee, 0x02, 0x13, 0x3d, 0xb2, 0xeb, 0x00 },
{ 0x7e, 0x9c, 0x0c, 0x54, 0x32, 0x5a, 0x9c, 0x15, 0x83, 0x6e, 0x00, 0x00 },
{ 0x36, 0x93, 0xe5, 0x72, 0xd1, 0xfd, 0xe4, 0xcd, 0xf0, 0x79, 0xe8, 0x60 },
{ 0xbf, 0xb2, 0xce, 0xc5, 0xab, 0xe1, 0xb0, 0xc7, 0x2e, 0x07, 0xfb, 0xe0 },
{ 0x7e, 0xe1, 0x82, 0x30, 0xc5, 0x83, 0xcc, 0xcc, 0x57, 0xd4, 0xb0, 0x80 },
{ 0xa0, 0x66, 0xcb, 0x2f, 0xed, 0xaf, 0xc9, 0xf5, 0x26, 0x64, 0x12, 0x60 },
{ 0xbb, 0x23, 0x72, 0x5a, 0xbc, 0x47, 0xcc, 0x5f, 0x4c, 0xc4, 0xcd, 0x20 },
{ 0xde, 0xd9, 0xdb, 0xa3, 0xbe, 0xe4, 0x0c, 0x59, 0xb5, 0x60, 0x9b, 0x40 },
{ 0xd9, 0xa7, 0x01, 0x6a, 0xc6, 0x53, 0xe6, 0xde, 0xcd, 0xc9, 0x03, 0x60 },
{ 0x9a, 0xd4, 0x6a, 0xed, 0x5f, 0x70, 0x7f, 0x28, 0x0a, 0xb5, 0xfc, 0x40 },
{ 0xe5, 0x92, 0x1c, 0x77, 0x82, 0x25, 0x87, 0x31, 0x6d, 0x7d, 0x3c, 0x20 },
{ 0x4f, 0x14, 0xda, 0x82, 0x42, 0xa8, 0xb8, 0x6d, 0xca, 0x73, 0x35, 0x20 },
{ 0x8b, 0x8b, 0x50, 0x7a, 0xd4, 0x67, 0xd4, 0x44, 0x1d, 0xf7, 0x70, 0xe0 },
{ 0x22, 0x83, 0x1c, 0x9c, 0xf1, 0x16, 0x94, 0x67, 0xad, 0x04, 0xb6, 0x80 },
{ 0x21, 0x3b, 0x83, 0x8f, 0xe2, 0xae, 0x54, 0xc3, 0x8e, 0xe7, 0x18, 0x00 },
{ 0x5d, 0x92, 0x6b, 0x6d, 0xd7, 0x1f, 0x08, 0x51, 0x81, 0xa4, 0xe1, 0x20 },
{ 0x66, 0xab, 0x79, 0xd4, 0xb2, 0x9e, 0xe6, 0xe6, 0x95, 0x09, 0xe5, 0x60 },
{ 0x95, 0x81, 0x48, 0x68, 0x2d, 0x74, 0x8a, 0x38, 0xdd, 0x68, 0xba, 0xa0 },
{ 0xb8, 0xce, 0x02, 0x0c, 0xf0, 0x69, 0xc3, 0x2a, 0x72, 0x3a, 0xb1, 0x40 },
{ 0xf4, 0x33, 0x1d, 0x6d, 0x46, 0x16, 0x07, 0xe9, 0x57, 0x52, 0x74, 0x60 },
{ 0x6d, 0xa2, 0x3b, 0xa4, 0x24, 0xb9, 0x59, 0x61, 0x33, 0xcf, 0x9c, 0x80 },
{ 0xa6, 0x36, 0xbc, 0xbc, 0x7b, 0x30, 0xc5, 0xfb, 0xea, 0xe6, 0x7f, 0xe0 },
{ 0x5c, 0xb0, 0xd8, 0x6a, 0x07, 0xdf, 0x65, 0x4a, 0x90, 0x89, 0xa2, 0x00 },
{ 0xf1, 0x1f, 0x10, 0x68, 0x48, 0x78, 0x0f, 0xc9, 0xec, 0xdd, 0x80, 0xa0 },
{ 0x1f, 0xbb, 0x53, 0x64, 0xfb, 0x8d, 0x2c, 0x9d, 0x73, 0x0d, 0x5b, 0xa0 },
{ 0xfc, 0xb8, 0x6b, 0xc7, 0x0a, 0x50, 0xc9, 0xd0, 0x2a, 0x5d, 0x03, 0x40 },
{ 0xa5, 0x34, 0x43, 0x30, 0x29, 0xea, 0xc1, 0x5f, 0x32, 0x2e, 0x34, 0xc0 },
{ 0xc9, 0x89, 0xd9, 0xc7, 0xc3, 0xd3, 0xb8, 0xc5, 0x5d, 0x75, 0x13, 0x00 },
{ 0x7b, 0xb3, 0x8b, 0x2f, 0x01, 0x86, 0xd4, 0x66, 0x43, 0xae, 0x96, 0x20 },
{ 0x26, 0x44, 0xeb, 0xad, 0xeb, 0x44, 0xb9, 0x46, 0x7d, 0x1f, 0x42, 0xc0 },
{ 0x60, 0x8c, 0xc8, 0x57, 0x59, 0x4b, 0xfb, 0xb5, 0x5d, 0x69, 0x60, 0x00 }
};
// Each row describes one LDPC parity check.
// Each number is an index into the codeword (1-origin).
// The codeword bits mentioned in each row must XOR to zero.
const uint8_t kFTX_LDPC_Nm[FTX_LDPC_M][7] = {
{ 4, 31, 59, 91, 92, 96, 153 },
{ 5, 32, 60, 93, 115, 146, 0 },
{ 6, 24, 61, 94, 122, 151, 0 },
{ 7, 33, 62, 95, 96, 143, 0 },
{ 8, 25, 63, 83, 93, 96, 148 },
{ 6, 32, 64, 97, 126, 138, 0 },
{ 5, 34, 65, 78, 98, 107, 154 },
{ 9, 35, 66, 99, 139, 146, 0 },
{ 10, 36, 67, 100, 107, 126, 0 },
{ 11, 37, 67, 87, 101, 139, 158 },
{ 12, 38, 68, 102, 105, 155, 0 },
{ 13, 39, 69, 103, 149, 162, 0 },
{ 8, 40, 70, 82, 104, 114, 145 },
{ 14, 41, 71, 88, 102, 123, 156 },
{ 15, 42, 59, 106, 123, 159, 0 },
{ 1, 33, 72, 106, 107, 157, 0 },
{ 16, 43, 73, 108, 141, 160, 0 },
{ 17, 37, 74, 81, 109, 131, 154 },
{ 11, 44, 75, 110, 121, 166, 0 },
{ 45, 55, 64, 111, 130, 161, 173 },
{ 8, 46, 71, 112, 119, 166, 0 },
{ 18, 36, 76, 89, 113, 114, 143 },
{ 19, 38, 77, 104, 116, 163, 0 },
{ 20, 47, 70, 92, 138, 165, 0 },
{ 2, 48, 74, 113, 128, 160, 0 },
{ 21, 45, 78, 83, 117, 121, 151 },
{ 22, 47, 58, 118, 127, 164, 0 },
{ 16, 39, 62, 112, 134, 158, 0 },
{ 23, 43, 79, 120, 131, 145, 0 },
{ 19, 35, 59, 73, 110, 125, 161 },
{ 20, 36, 63, 94, 136, 161, 0 },
{ 14, 31, 79, 98, 132, 164, 0 },
{ 3, 44, 80, 124, 127, 169, 0 },
{ 19, 46, 81, 117, 135, 167, 0 },
{ 7, 49, 58, 90, 100, 105, 168 },
{ 12, 50, 61, 118, 119, 144, 0 },
{ 13, 51, 64, 114, 118, 157, 0 },
{ 24, 52, 76, 129, 148, 149, 0 },
{ 25, 53, 69, 90, 101, 130, 156 },
{ 20, 46, 65, 80, 120, 140, 170 },
{ 21, 54, 77, 100, 140, 171, 0 },
{ 35, 82, 133, 142, 171, 174, 0 },
{ 14, 30, 83, 113, 125, 170, 0 },
{ 4, 29, 68, 120, 134, 173, 0 },
{ 1, 4, 52, 57, 86, 136, 152 },
{ 26, 51, 56, 91, 122, 137, 168 },
{ 52, 84, 110, 115, 145, 168, 0 },
{ 7, 50, 81, 99, 132, 173, 0 },
{ 23, 55, 67, 95, 172, 174, 0 },
{ 26, 41, 77, 109, 141, 148, 0 },
{ 2, 27, 41, 61, 62, 115, 133 },
{ 27, 40, 56, 124, 125, 126, 0 },
{ 18, 49, 55, 124, 141, 167, 0 },
{ 6, 33, 85, 108, 116, 156, 0 },
{ 28, 48, 70, 85, 105, 129, 158 },
{ 9, 54, 63, 131, 147, 155, 0 },
{ 22, 53, 68, 109, 121, 174, 0 },
{ 3, 13, 48, 78, 95, 123, 0 },
{ 31, 69, 133, 150, 155, 169, 0 },
{ 12, 43, 66, 89, 97, 135, 159 },
{ 5, 39, 75, 102, 136, 167, 0 },
{ 2, 54, 86, 101, 135, 164, 0 },
{ 15, 56, 87, 108, 119, 171, 0 },
{ 10, 44, 82, 91, 111, 144, 149 },
{ 23, 34, 71, 94, 127, 153, 0 },
{ 11, 49, 88, 92, 142, 157, 0 },
{ 29, 34, 87, 97, 147, 162, 0 },
{ 30, 50, 60, 86, 137, 142, 162 },
{ 10, 53, 66, 84, 112, 128, 165 },
{ 22, 57, 85, 93, 140, 159, 0 },
{ 28, 32, 72, 103, 132, 166, 0 },
{ 28, 29, 84, 88, 117, 143, 150 },
{ 1, 26, 45, 80, 128, 147, 0 },
{ 17, 27, 89, 103, 116, 153, 0 },
{ 51, 57, 98, 163, 165, 172, 0 },
{ 21, 37, 73, 138, 152, 169, 0 },
{ 16, 47, 76, 130, 137, 154, 0 },
{ 3, 24, 30, 72, 104, 139, 0 },
{ 9, 40, 90, 106, 134, 151, 0 },
{ 15, 58, 60, 74, 111, 150, 163 },
{ 18, 42, 79, 144, 146, 152, 0 },
{ 25, 38, 65, 99, 122, 160, 0 },
{ 17, 42, 75, 129, 170, 172, 0 }
};
// Each row corresponds to a codeword bit.
// The numbers indicate which three LDPC parity checks (rows in Nm) refer to the codeword bit.
// 1-origin.
const uint8_t kFTX_LDPC_Mn[FTX_LDPC_N][3] = {
{ 16, 45, 73 },
{ 25, 51, 62 },
{ 33, 58, 78 },
{ 1, 44, 45 },
{ 2, 7, 61 },
{ 3, 6, 54 },
{ 4, 35, 48 },
{ 5, 13, 21 },
{ 8, 56, 79 },
{ 9, 64, 69 },
{ 10, 19, 66 },
{ 11, 36, 60 },
{ 12, 37, 58 },
{ 14, 32, 43 },
{ 15, 63, 80 },
{ 17, 28, 77 },
{ 18, 74, 83 },
{ 22, 53, 81 },
{ 23, 30, 34 },
{ 24, 31, 40 },
{ 26, 41, 76 },
{ 27, 57, 70 },
{ 29, 49, 65 },
{ 3, 38, 78 },
{ 5, 39, 82 },
{ 46, 50, 73 },
{ 51, 52, 74 },
{ 55, 71, 72 },
{ 44, 67, 72 },
{ 43, 68, 78 },
{ 1, 32, 59 },
{ 2, 6, 71 },
{ 4, 16, 54 },
{ 7, 65, 67 },
{ 8, 30, 42 },
{ 9, 22, 31 },
{ 10, 18, 76 },
{ 11, 23, 82 },
{ 12, 28, 61 },
{ 13, 52, 79 },
{ 14, 50, 51 },
{ 15, 81, 83 },
{ 17, 29, 60 },
{ 19, 33, 64 },
{ 20, 26, 73 },
{ 21, 34, 40 },
{ 24, 27, 77 },
{ 25, 55, 58 },
{ 35, 53, 66 },
{ 36, 48, 68 },
{ 37, 46, 75 },
{ 38, 45, 47 },
{ 39, 57, 69 },
{ 41, 56, 62 },
{ 20, 49, 53 },
{ 46, 52, 63 },
{ 45, 70, 75 },
{ 27, 35, 80 },
{ 1, 15, 30 },
{ 2, 68, 80 },
{ 3, 36, 51 },
{ 4, 28, 51 },
{ 5, 31, 56 },
{ 6, 20, 37 },
{ 7, 40, 82 },
{ 8, 60, 69 },
{ 9, 10, 49 },
{ 11, 44, 57 },
{ 12, 39, 59 },
{ 13, 24, 55 },
{ 14, 21, 65 },
{ 16, 71, 78 },
{ 17, 30, 76 },
{ 18, 25, 80 },
{ 19, 61, 83 },
{ 22, 38, 77 },
{ 23, 41, 50 },
{ 7, 26, 58 },
{ 29, 32, 81 },
{ 33, 40, 73 },
{ 18, 34, 48 },
{ 13, 42, 64 },
{ 5, 26, 43 },
{ 47, 69, 72 },
{ 54, 55, 70 },
{ 45, 62, 68 },
{ 10, 63, 67 },
{ 14, 66, 72 },
{ 22, 60, 74 },
{ 35, 39, 79 },
{ 1, 46, 64 },
{ 1, 24, 66 },
{ 2, 5, 70 },
{ 3, 31, 65 },
{ 4, 49, 58 },
{ 1, 4, 5 },
{ 6, 60, 67 },
{ 7, 32, 75 },
{ 8, 48, 82 },
{ 9, 35, 41 },
{ 10, 39, 62 },
{ 11, 14, 61 },
{ 12, 71, 74 },
{ 13, 23, 78 },
{ 11, 35, 55 },
{ 15, 16, 79 },
{ 7, 9, 16 },
{ 17, 54, 63 },
{ 18, 50, 57 },
{ 19, 30, 47 },
{ 20, 64, 80 },
{ 21, 28, 69 },
{ 22, 25, 43 },
{ 13, 22, 37 },
{ 2, 47, 51 },
{ 23, 54, 74 },
{ 26, 34, 72 },
{ 27, 36, 37 },
{ 21, 36, 63 },
{ 29, 40, 44 },
{ 19, 26, 57 },
{ 3, 46, 82 },
{ 14, 15, 58 },
{ 33, 52, 53 },
{ 30, 43, 52 },
{ 6, 9, 52 },
{ 27, 33, 65 },
{ 25, 69, 73 },
{ 38, 55, 83 },
{ 20, 39, 77 },
{ 18, 29, 56 },
{ 32, 48, 71 },
{ 42, 51, 59 },
{ 28, 44, 79 },
{ 34, 60, 62 },
{ 31, 45, 61 },
{ 46, 68, 77 },
{ 6, 24, 76 },
{ 8, 10, 78 },
{ 40, 41, 70 },
{ 17, 50, 53 },
{ 42, 66, 68 },
{ 4, 22, 72 },
{ 36, 64, 81 },
{ 13, 29, 47 },
{ 2, 8, 81 },
{ 56, 67, 73 },
{ 5, 38, 50 },
{ 12, 38, 64 },
{ 59, 72, 80 },
{ 3, 26, 79 },
{ 45, 76, 81 },
{ 1, 65, 74 },
{ 7, 18, 77 },
{ 11, 56, 59 },
{ 14, 39, 54 },
{ 16, 37, 66 },
{ 10, 28, 55 },
{ 15, 60, 70 },
{ 17, 25, 82 },
{ 20, 30, 31 },
{ 12, 67, 68 },
{ 23, 75, 80 },
{ 27, 32, 62 },
{ 24, 69, 75 },
{ 19, 21, 71 },
{ 34, 53, 61 },
{ 35, 46, 47 },
{ 33, 59, 76 },
{ 40, 43, 83 },
{ 41, 42, 63 },
{ 49, 75, 83 },
{ 20, 44, 48 },
{ 42, 49, 57 }
};
const uint8_t kFTX_LDPC_Num_rows[FTX_LDPC_M] = {
7, 6, 6, 6, 7, 6, 7, 6, 6, 7, 6, 6, 7, 7, 6, 6,
6, 7, 6, 7, 6, 7, 6, 6, 6, 7, 6, 6, 6, 7, 6, 6,
6, 6, 7, 6, 6, 6, 7, 7, 6, 6, 6, 6, 7, 7, 6, 6,
6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 6, 7, 6, 6, 6, 7,
6, 6, 6, 7, 7, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 7,
6, 6, 6
};
+121
View File
@@ -0,0 +1,121 @@
#ifndef _INCLUDE_CONSTANTS_H_
#define _INCLUDE_CONSTANTS_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
#define FT8_SYMBOL_PERIOD (0.160f) ///< FT8 symbol duration, defines tone deviation in Hz and symbol rate
#define FT8_SLOT_TIME (15.0f) ///< FT8 slot period
#define FT4_SYMBOL_PERIOD (0.048f) ///< FT4 symbol duration, defines tone deviation in Hz and symbol rate
#define FT4_SLOT_TIME (7.5f) ///< FT4 slot period
#define FT2_SYMBOL_PERIOD (0.024f) ///< FT2 symbol duration (288 samples @ 12 kHz)
#define FT2_SLOT_TIME (3.75f) ///< FT2 slot period
// Define FT8 symbol counts
// FT8 message structure:
// S D1 S D2 S
// S - sync block (7 symbols of Costas pattern)
// D1 - first data block (29 symbols each encoding 3 bits)
#define FT8_ND (58) ///< Data symbols
#define FT8_NN (79) ///< Total channel symbols (FT8_NS + FT8_ND)
#define FT8_LENGTH_SYNC (7) ///< Length of each sync group
#define FT8_NUM_SYNC (3) ///< Number of sync groups
#define FT8_SYNC_OFFSET (36) ///< Offset between sync groups
// Define FT4 symbol counts
// FT4 message structure:
// R Sa D1 Sb D2 Sc D3 Sd R
// R - ramping symbol (no payload information conveyed)
// Sx - one of four _different_ sync blocks (4 symbols of Costas pattern)
// Dy - data block (29 symbols each encoding 2 bits)
#define FT4_ND (87) ///< Data symbols
#define FT4_NR (2) ///< Ramp symbols (beginning + end)
#define FT4_NN (105) ///< Total channel symbols (FT4_NS + FT4_ND + FT4_NR)
#define FT4_LENGTH_SYNC (4) ///< Length of each sync group
#define FT4_NUM_SYNC (4) ///< Number of sync groups
#define FT4_SYNC_OFFSET (33) ///< Offset between sync groups
// FT2 reuses the FT4 channel structure with a shorter slot and symbol period.
#define FT2_ND FT4_ND
#define FT2_NR FT4_NR
#define FT2_NN FT4_NN
#define FT2_LENGTH_SYNC FT4_LENGTH_SYNC
#define FT2_NUM_SYNC FT4_NUM_SYNC
#define FT2_SYNC_OFFSET FT4_SYNC_OFFSET
// Define LDPC parameters
#define FTX_LDPC_N (174) ///< Number of bits in the encoded message (payload with LDPC checksum bits)
#define FTX_LDPC_K (91) ///< Number of payload bits (including CRC)
#define FTX_LDPC_M (83) ///< Number of LDPC checksum bits (FTX_LDPC_N - FTX_LDPC_K)
#define FTX_LDPC_N_BYTES ((FTX_LDPC_N + 7) / 8) ///< Number of whole bytes needed to store 174 bits (full message)
#define FTX_LDPC_K_BYTES ((FTX_LDPC_K + 7) / 8) ///< Number of whole bytes needed to store 91 bits (payload + CRC only)
// Define CRC parameters
#define FT8_CRC_POLYNOMIAL ((uint16_t)0x2757u) ///< CRC-14 polynomial without the leading (MSB) 1
#define FT8_CRC_WIDTH (14)
typedef enum
{
FTX_PROTOCOL_FT4,
FTX_PROTOCOL_FT8,
FTX_PROTOCOL_FT2
} ftx_protocol_t;
static inline float ftx_protocol_symbol_period(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT8)
? FT8_SYMBOL_PERIOD
: ((protocol == FTX_PROTOCOL_FT2) ? FT2_SYMBOL_PERIOD : FT4_SYMBOL_PERIOD);
}
static inline float ftx_protocol_slot_time(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT8)
? FT8_SLOT_TIME
: ((protocol == FTX_PROTOCOL_FT2) ? FT2_SLOT_TIME : FT4_SLOT_TIME);
}
static inline int ftx_protocol_uses_ft4_layout(ftx_protocol_t protocol)
{
return (protocol == FTX_PROTOCOL_FT4) || (protocol == FTX_PROTOCOL_FT2);
}
/// Costas 7x7 tone pattern for synchronization
extern const uint8_t kFT8_Costas_pattern[7];
extern const uint8_t kFT4_Costas_pattern[4][4];
/// Gray code map to encode 8 symbols (tones)
extern const uint8_t kFT8_Gray_map[8];
extern const uint8_t kFT4_Gray_map[4];
extern const uint8_t kFT4_XOR_sequence[10];
/// Parity generator matrix for (174,91) LDPC code, stored in bitpacked format (MSB first)
extern const uint8_t kFTX_LDPC_generator[FTX_LDPC_M][FTX_LDPC_K_BYTES];
/// LDPC(174,91) parity check matrix, containing 83 rows,
/// each row describes one parity check,
/// each number is an index into the codeword (1-origin).
/// The codeword bits mentioned in each row must xor to zero.
/// From WSJT-X's ldpc_174_91_c_reordered_parity.f90.
extern const uint8_t kFTX_LDPC_Nm[FTX_LDPC_M][7];
/// Mn from WSJT-X's bpdecode174.f90. Each row corresponds to a codeword bit.
/// The numbers indicate which three parity checks (rows in Nm) refer to the codeword bit.
/// The numbers use 1 as the origin (first entry).
extern const uint8_t kFTX_LDPC_Mn[FTX_LDPC_N][3];
/// Number of rows (columns in C/C++) in the array Nm.
extern const uint8_t kFTX_LDPC_Num_rows[FTX_LDPC_M];
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_CONSTANTS_H_
+63
View File
@@ -0,0 +1,63 @@
#include "crc.h"
#include "constants.h"
#define TOPBIT (1u << (FT8_CRC_WIDTH - 1))
// Compute 14-bit CRC for a sequence of given number of bits
// Adapted from https://barrgroup.com/Embedded-Systems/How-To/CRC-Calculation-C-Code
// [IN] message - byte sequence (MSB first)
// [IN] num_bits - number of bits in the sequence
uint16_t ftx_compute_crc(const uint8_t message[], int num_bits)
{
uint16_t remainder = 0;
int idx_byte = 0;
// Perform modulo-2 division, a bit at a time.
for (int idx_bit = 0; idx_bit < num_bits; ++idx_bit)
{
if (idx_bit % 8 == 0)
{
// Bring the next byte into the remainder.
remainder ^= (message[idx_byte] << (FT8_CRC_WIDTH - 8));
++idx_byte;
}
// Try to divide the current data bit.
if (remainder & TOPBIT)
{
remainder = (remainder << 1) ^ FT8_CRC_POLYNOMIAL;
}
else
{
remainder = (remainder << 1);
}
}
return remainder & ((TOPBIT << 1) - 1u);
}
uint16_t ftx_extract_crc(const uint8_t a91[])
{
uint16_t chksum = ((a91[9] & 0x07) << 11) | (a91[10] << 3) | (a91[11] >> 5);
return chksum;
}
void ftx_add_crc(const uint8_t payload[], uint8_t a91[])
{
// Copy 77 bits of payload data
for (int i = 0; i < 10; i++)
a91[i] = payload[i];
// Clear 3 bits after the payload to make 82 bits
a91[9] &= 0xF8u;
a91[10] = 0;
// Calculate CRC of 82 bits (77 + 5 zeros)
// 'The CRC is calculated on the source-encoded message, zero-extended from 77 to 82 bits'
uint16_t checksum = ftx_compute_crc(a91, 96 - 14);
// Store the CRC at the end of 77 bit message
a91[9] |= (uint8_t)(checksum >> 11);
a91[10] = (uint8_t)(checksum >> 3);
a91[11] = (uint8_t)(checksum << 5);
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef _INCLUDE_CRC_H_
#define _INCLUDE_CRC_H_
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C"
{
#endif
// Compute 14-bit CRC for a sequence of given number of bits using FT8/FT4 CRC polynomial
// [IN] message - byte sequence (MSB first)
// [IN] num_bits - number of bits in the sequence
uint16_t ftx_compute_crc(const uint8_t message[], int num_bits);
/// Extract the FT8/FT4 CRC of a packed message (during decoding)
/// @param[in] a91 77 bits of payload data + CRC
/// @return Extracted CRC
uint16_t ftx_extract_crc(const uint8_t a91[]);
/// Add FT8/FT4 CRC to a packed message (during encoding)
/// @param[in] payload 77 bits of payload data
/// @param[out] a91 91 bits of payload data + CRC
void ftx_add_crc(const uint8_t payload[], uint8_t a91[]);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_CRC_H_
+22
View File
@@ -0,0 +1,22 @@
#ifndef _DEBUG_H_INCLUDED_
#define _DEBUG_H_INCLUDED_
#define LOG_DEBUG 0
#define LOG_INFO 1
#define LOG_WARN 2
#define LOG_ERROR 3
#define LOG_FATAL 4
#ifdef LOG_LEVEL
#ifndef LOG_PRINTF
#include <stdio.h>
#define LOG_PRINTF(...) fprintf(stderr, __VA_ARGS__)
#endif
#define LOG(level, ...) \
if (level >= LOG_LEVEL) \
LOG_PRINTF(__VA_ARGS__)
#else // ifdef LOG_LEVEL
#define LOG(level, ...)
#endif
#endif // _DEBUG_H_INCLUDED_
+773
View File
@@ -0,0 +1,773 @@
#include "decode.h"
#include "constants.h"
#include "crc.h"
#include "ldpc.h"
#include <stdbool.h>
#include <math.h>
#include <complex.h>
// #define LOG_LEVEL LOG_DEBUG
// #include "debug.h"
// Lookup table for y = 10*log10(1 + 10^(x/10)), where
// y - increase in signal level dB when adding a weaker independent signal
// x - specific relative strength of the weaker signal in dB
// Table index corresponds to x in dB (index 0: 0 dB, index 1: -1 dB etc)
static const float db_power_sum[40] = {
3.01029995663981f, 2.53901891043867f, 2.1244260279434f, 1.76434862436485f, 1.45540463109294f,
1.19331048066095f, 0.973227937086954f, 0.790097496525665f, 0.638920341433796f, 0.514969420252302f,
0.413926851582251f, 0.331956199884278f, 0.265723755961025f, 0.212384019142551f, 0.16954289279533f,
0.135209221080382f, 0.10774225511957f, 0.085799992300358f, 0.06829128312453f, 0.054333142200458f,
0.043213737826426f, 0.034360947517284f, 0.027316043349389f, 0.021711921641451f, 0.017255250287928f,
0.013711928326833f, 0.010895305999614f, 0.008656680827934f, 0.006877654943187f, 0.005464004928574f,
0.004340774793186f, 0.003448354310253f, 0.002739348814965f, 0.002176083232619f, 0.001728613409904f,
0.001373142636584f, 0.001090761428665f, 0.000866444976964f, 0.000688255828734f, 0.000546709946839f
};
/// Compute log likelihood log(p(1) / p(0)) of 174 message bits for later use in soft-decision LDPC decoding
/// @param[in] wf Waterfall data collected during message slot
/// @param[in] cand Candidate to extract the message from
/// @param[in] code_map Symbol encoding map
/// @param[out] log174 Output of decoded log likelihoods for each of the 174 message bits
static void ft4_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174);
static void ft2_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174);
static void ft8_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174);
/// Packs a string of bits each represented as a zero/non-zero byte in bit_array[],
/// as a string of packed bits starting from the MSB of the first byte of packed[]
/// @param[in] plain Array of bits (0 and nonzero values) with num_bits entires
/// @param[in] num_bits Number of bits (entries) passed in bit_array
/// @param[out] packed Byte-packed bits representing the data in bit_array
static void pack_bits(const uint8_t bit_array[], int num_bits, uint8_t packed[]);
static float max2(float a, float b);
static float max4(float a, float b, float c, float d);
static void heapify_down(ftx_candidate_t heap[], int heap_size);
static void heapify_up(ftx_candidate_t heap[], int heap_size);
static void ftx_normalize_logl(float* log174);
static void ft4_extract_symbol(const WF_ELEM_T* wf, float* logl);
static void ft2_extract_logl_sequence(const float complex symbols[4][FT2_NN - FT2_NR], int start_sym, int n_syms, float* metrics);
static void ft8_extract_symbol(const WF_ELEM_T* wf, float* logl);
static void ft8_decode_multi_symbols(const WF_ELEM_T* wf, int num_bins, int n_syms, int bit_idx, float* log174);
static inline float complex wf_elem_to_complex(const WF_ELEM_T elem)
{
float mag = WF_ELEM_MAG(elem);
float amplitude = powf(10.0f, mag / 20.0f);
return amplitude * cexpf(I * elem.phase);
}
static const WF_ELEM_T* get_cand_mag(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
int offset = candidate->time_offset;
offset = (offset * wf->time_osr) + candidate->time_sub;
offset = (offset * wf->freq_osr) + candidate->freq_sub;
offset = (offset * wf->num_bins) + candidate->freq_offset;
return wf->mag + offset;
}
static int ft8_sync_score(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
int score = 0;
int num_average = 0;
// Get the pointer to symbol 0 of the candidate
const WF_ELEM_T* mag_cand = get_cand_mag(wf, candidate);
// Compute average score over sync symbols (m+k = 0-7, 36-43, 72-79)
for (int m = 0; m < FT8_NUM_SYNC; ++m)
{
for (int k = 0; k < FT8_LENGTH_SYNC; ++k)
{
int block = (FT8_SYNC_OFFSET * m) + k; // relative to the message
int block_abs = candidate->time_offset + block; // relative to the captured signal
// Check for time boundaries
if (block_abs < 0)
continue;
if (block_abs >= wf->num_blocks)
break;
// Get the pointer to symbol 'block' of the candidate
const WF_ELEM_T* p8 = mag_cand + (block * wf->block_stride);
// Weighted difference between the expected and all other symbols
// Does not work as well as the alternative score below
// score += 8 * p8[kFT8_Costas_pattern[k]] -
// p8[0] - p8[1] - p8[2] - p8[3] -
// p8[4] - p8[5] - p8[6] - p8[7];
// ++num_average;
// Check only the neighbors of the expected symbol frequency- and time-wise
int sm = kFT8_Costas_pattern[k]; // Index of the expected bin
if (sm > 0)
{
// look at one frequency bin lower
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm - 1]);
++num_average;
}
if (sm < 7)
{
// look at one frequency bin higher
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm + 1]);
++num_average;
}
if ((k > 0) && (block_abs > 0))
{
// look one symbol back in time
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm - wf->block_stride]);
++num_average;
}
if (((k + 1) < FT8_LENGTH_SYNC) && ((block_abs + 1) < wf->num_blocks))
{
// look one symbol forward in time
score += WF_ELEM_MAG_INT(p8[sm]) - WF_ELEM_MAG_INT(p8[sm + wf->block_stride]);
++num_average;
}
}
}
if (num_average > 0)
score /= num_average;
return score;
}
static int ft2_sync_score(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
const WF_ELEM_T* mag_cand = get_cand_mag(wf, candidate);
float score = 0.0f;
int groups = 0;
for (int m = 0; m < FT2_NUM_SYNC; ++m)
{
float complex sum = 0.0f;
bool complete = true;
for (int k = 0; k < FT2_LENGTH_SYNC; ++k)
{
int block = 1 + (FT2_SYNC_OFFSET * m) + k;
int block_abs = candidate->time_offset + block;
if ((block_abs < 0) || (block_abs >= wf->num_blocks))
{
complete = false;
break;
}
const WF_ELEM_T* sym = mag_cand + (block * wf->block_stride);
int tone = kFT4_Costas_pattern[m][k];
sum += wf_elem_to_complex(sym[tone]);
}
if (!complete)
continue;
score += cabsf(sum);
++groups;
}
if (groups == 0)
return 0;
return (int)lroundf((score / groups) * 8.0f);
}
static int ft4_sync_score(const ftx_waterfall_t* wf, const ftx_candidate_t* candidate)
{
int score = 0;
int num_average = 0;
// Get the pointer to symbol 0 of the candidate
const WF_ELEM_T* mag_cand = get_cand_mag(wf, candidate);
// Compute average score over sync symbols (block = 1-4, 34-37, 67-70, 100-103)
for (int m = 0; m < FT4_NUM_SYNC; ++m)
{
for (int k = 0; k < FT4_LENGTH_SYNC; ++k)
{
int block = 1 + (FT4_SYNC_OFFSET * m) + k;
int block_abs = candidate->time_offset + block;
// Check for time boundaries
if (block_abs < 0)
continue;
if (block_abs >= wf->num_blocks)
break;
// Get the pointer to symbol 'block' of the candidate
const WF_ELEM_T* p4 = mag_cand + (block * wf->block_stride);
int sm = kFT4_Costas_pattern[m][k]; // Index of the expected bin
// score += (4 * p4[sm]) - p4[0] - p4[1] - p4[2] - p4[3];
// num_average += 4;
// Check only the neighbors of the expected symbol frequency- and time-wise
if (sm > 0)
{
// look at one frequency bin lower
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm - 1]);
++num_average;
}
if (sm < 3)
{
// look at one frequency bin higher
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm + 1]);
++num_average;
}
if ((k > 0) && (block_abs > 0))
{
// look one symbol back in time
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm - wf->block_stride]);
++num_average;
}
if (((k + 1) < FT4_LENGTH_SYNC) && ((block_abs + 1) < wf->num_blocks))
{
// look one symbol forward in time
score += WF_ELEM_MAG_INT(p4[sm]) - WF_ELEM_MAG_INT(p4[sm + wf->block_stride]);
++num_average;
}
}
}
if (num_average > 0)
score /= num_average;
return score;
}
int ftx_find_candidates(const ftx_waterfall_t* wf, int num_candidates, ftx_candidate_t heap[], int min_score)
{
bool is_ft2 = (wf->protocol == FTX_PROTOCOL_FT2);
int (*sync_fun)(const ftx_waterfall_t*, const ftx_candidate_t*) =
is_ft2 ? ft2_sync_score : (ftx_protocol_uses_ft4_layout(wf->protocol) ? ft4_sync_score : ft8_sync_score);
int num_tones = ftx_protocol_uses_ft4_layout(wf->protocol) ? 4 : 8;
int time_offset_min = -10;
int time_offset_max = 20;
if (is_ft2)
{
time_offset_min = -2;
time_offset_max = wf->num_blocks - FT2_NN + 2;
if (time_offset_max <= time_offset_min)
{
time_offset_max = time_offset_min + 1;
}
}
else if (wf->protocol == FTX_PROTOCOL_FT4)
{
// Keep roughly the same +/- seconds search span used by FT8.
// FT4 symbols are much shorter, so it needs a wider symbol-index window.
time_offset_min = -34;
time_offset_max = wf->num_blocks - FT4_NN + 34;
if (time_offset_max <= time_offset_min)
{
time_offset_max = time_offset_min + 1;
}
}
int heap_size = 0;
ftx_candidate_t candidate;
// Here we allow time offsets that exceed signal boundaries, as long as we still have all data bits.
// I.e. we can afford to skip the first 7 or the last 7 Costas symbols, as long as we track how many
// sync symbols we included in the score, so the score is averaged.
for (candidate.time_sub = 0; candidate.time_sub < wf->time_osr; ++candidate.time_sub)
{
for (candidate.freq_sub = 0; candidate.freq_sub < wf->freq_osr; ++candidate.freq_sub)
{
for (candidate.time_offset = time_offset_min; candidate.time_offset < time_offset_max; ++candidate.time_offset)
{
for (candidate.freq_offset = 0; (candidate.freq_offset + num_tones - 1) < wf->num_bins; ++candidate.freq_offset)
{
candidate.score = sync_fun(wf, &candidate);
if (candidate.score < min_score)
continue;
// If the heap is full AND the current candidate is better than
// the worst in the heap, we remove the worst and make space
if ((heap_size == num_candidates) && (candidate.score > heap[0].score))
{
--heap_size;
heap[0] = heap[heap_size];
heapify_down(heap, heap_size);
}
// If there's free space in the heap, we add the current candidate
if (heap_size < num_candidates)
{
heap[heap_size] = candidate;
++heap_size;
heapify_up(heap, heap_size);
}
}
}
}
}
// Sort the candidates by sync strength - here we benefit from the heap structure
int len_unsorted = heap_size;
while (len_unsorted > 1)
{
// Take the top (index 0) element which is guaranteed to have the smallest score,
// exchange it with the last element in the heap, and decrease the heap size.
// Then restore the heap property in the new, smaller heap.
// At the end the elements will be sorted in descending order.
ftx_candidate_t tmp = heap[len_unsorted - 1];
heap[len_unsorted - 1] = heap[0];
heap[0] = tmp;
len_unsorted--;
heapify_down(heap, len_unsorted);
}
return heap_size;
}
static void ft4_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174)
{
const WF_ELEM_T* mag = get_cand_mag(wf, cand); // Pointer to 4 magnitude bins of the first symbol
// Go over FSK tones and skip Costas sync symbols
for (int k = 0; k < FT4_ND; ++k)
{
// Skip either 5, 9 or 13 sync symbols
// TODO: replace magic numbers with constants
int sym_idx = k + ((k < 29) ? 5 : ((k < 58) ? 9 : 13));
int bit_idx = 2 * k;
// Check for time boundaries
int block = cand->time_offset + sym_idx;
if ((block < 0) || (block >= wf->num_blocks))
{
log174[bit_idx + 0] = 0;
log174[bit_idx + 1] = 0;
}
else
{
ft4_extract_symbol(mag + (sym_idx * wf->block_stride), log174 + bit_idx);
}
}
}
static void ft2_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174)
{
const WF_ELEM_T* mag = get_cand_mag(wf, cand);
float complex symbols[4][FT2_NN - FT2_NR];
float metric1[2 * (FT2_NN - FT2_NR)] = { 0 };
float metric2[2 * (FT2_NN - FT2_NR)] = { 0 };
float metric4[2 * (FT2_NN - FT2_NR)] = { 0 };
for (int frame_sym = 0; frame_sym < (FT2_NN - FT2_NR); ++frame_sym)
{
int sym_idx = frame_sym + 1; // skip ramp-up symbol
int block = cand->time_offset + sym_idx;
if ((block < 0) || (block >= wf->num_blocks))
{
for (int tone = 0; tone < 4; ++tone)
{
symbols[tone][frame_sym] = 0.0f;
}
continue;
}
const WF_ELEM_T* sym = mag + (sym_idx * wf->block_stride);
for (int tone = 0; tone < 4; ++tone)
{
symbols[tone][frame_sym] = wf_elem_to_complex(sym[tone]);
}
}
for (int start = 0; start <= (FT2_NN - FT2_NR) - 1; start += 1)
{
ft2_extract_logl_sequence(symbols, start, 1, metric1 + (2 * start));
}
for (int start = 0; start <= (FT2_NN - FT2_NR) - 2; start += 2)
{
ft2_extract_logl_sequence(symbols, start, 2, metric2 + (2 * start));
}
for (int start = 0; start <= (FT2_NN - FT2_NR) - 4; start += 4)
{
ft2_extract_logl_sequence(symbols, start, 4, metric4 + (2 * start));
}
metric2[204] = metric1[204];
metric2[205] = metric1[205];
metric4[200] = metric2[200];
metric4[201] = metric2[201];
metric4[202] = metric2[202];
metric4[203] = metric2[203];
metric4[204] = metric1[204];
metric4[205] = metric1[205];
for (int data_sym = 0; data_sym < FT2_ND; ++data_sym)
{
int frame_sym = data_sym + ((data_sym < 29) ? 4 : ((data_sym < 58) ? 8 : 12));
int src_bit = 2 * frame_sym;
int dst_bit = 2 * data_sym;
float a0 = metric1[src_bit + 0];
float b0 = metric2[src_bit + 0];
float c0 = metric4[src_bit + 0];
float a1 = metric1[src_bit + 1];
float b1 = metric2[src_bit + 1];
float c1 = metric4[src_bit + 1];
log174[dst_bit + 0] = (fabsf(a0) >= fabsf(b0) && fabsf(a0) >= fabsf(c0)) ? a0 : ((fabsf(b0) >= fabsf(c0)) ? b0 : c0);
log174[dst_bit + 1] = (fabsf(a1) >= fabsf(b1) && fabsf(a1) >= fabsf(c1)) ? a1 : ((fabsf(b1) >= fabsf(c1)) ? b1 : c1);
}
}
static void ft8_extract_likelihood(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, float* log174)
{
const WF_ELEM_T* mag = get_cand_mag(wf, cand); // Pointer to 8 magnitude bins of the first symbol
// Go over FSK tones and skip Costas sync symbols
for (int k = 0; k < FT8_ND; ++k)
{
// Skip either 7 or 14 sync symbols
// TODO: replace magic numbers with constants
int sym_idx = k + ((k < 29) ? 7 : 14);
int bit_idx = 3 * k;
// Check for time boundaries
int block = cand->time_offset + sym_idx;
if ((block < 0) || (block >= wf->num_blocks))
{
log174[bit_idx + 0] = 0;
log174[bit_idx + 1] = 0;
log174[bit_idx + 2] = 0;
}
else
{
ft8_extract_symbol(mag + (sym_idx * wf->block_stride), log174 + bit_idx);
}
}
}
static void ftx_normalize_logl(float* log174)
{
// Compute the variance of log174
float sum = 0;
float sum2 = 0;
for (int i = 0; i < FTX_LDPC_N; ++i)
{
sum += log174[i];
sum2 += log174[i] * log174[i];
}
float inv_n = 1.0f / FTX_LDPC_N;
float variance = (sum2 - (sum * sum * inv_n)) * inv_n;
// Normalize log174 distribution and scale it with experimentally found coefficient
float norm_factor = sqrtf(24.0f / variance);
for (int i = 0; i < FTX_LDPC_N; ++i)
{
log174[i] *= norm_factor;
}
}
bool ftx_decode_candidate(const ftx_waterfall_t* wf, const ftx_candidate_t* cand, int max_iterations, ftx_message_t* message, ftx_decode_status_t* status)
{
float log174[FTX_LDPC_N]; // message bits encoded as likelihood
if (wf->protocol == FTX_PROTOCOL_FT2)
{
ft2_extract_likelihood(wf, cand, log174);
}
else if (ftx_protocol_uses_ft4_layout(wf->protocol))
{
ft4_extract_likelihood(wf, cand, log174);
}
else
{
ft8_extract_likelihood(wf, cand, log174);
}
ftx_normalize_logl(log174);
uint8_t plain174[FTX_LDPC_N]; // message bits (0/1)
bp_decode(log174, max_iterations, plain174, &status->ldpc_errors);
// ldpc_decode(log174, max_iterations, plain174, &status->ldpc_errors);
if (status->ldpc_errors > 0)
{
return false;
}
// Extract payload + CRC (first FTX_LDPC_K bits) packed into a byte array
uint8_t a91[FTX_LDPC_K_BYTES];
pack_bits(plain174, FTX_LDPC_K, a91);
// Extract CRC and check it
status->crc_extracted = ftx_extract_crc(a91);
// [1]: 'The CRC is calculated on the source-encoded message, zero-extended from 77 to 82 bits.'
a91[9] &= 0xF8;
a91[10] &= 0x00;
status->crc_calculated = ftx_compute_crc(a91, 96 - 14);
if (status->crc_extracted != status->crc_calculated)
{
return false;
}
// Reuse CRC value as a hash for the message (TODO: 14 bits only, should perhaps use full 16 or 32 bits?)
message->hash = status->crc_calculated;
if (ftx_protocol_uses_ft4_layout(wf->protocol))
{
// '[..] for FT4 only, in order to avoid transmitting a long string of zeros when sending CQ messages,
// the assembled 77-bit message is bitwise exclusive-ORed with [a] pseudorandom sequence before computing the CRC and FEC parity bits'
for (int i = 0; i < 10; ++i)
{
message->payload[i] = a91[i] ^ kFT4_XOR_sequence[i];
}
}
else
{
for (int i = 0; i < 10; ++i)
{
message->payload[i] = a91[i];
}
}
// LOG(LOG_DEBUG, "Decoded message (CRC %04x), trying to unpack...\n", status->crc_extracted);
return true;
}
static float max2(float a, float b)
{
return (a >= b) ? a : b;
}
static float max4(float a, float b, float c, float d)
{
return max2(max2(a, b), max2(c, d));
}
static void heapify_down(ftx_candidate_t heap[], int heap_size)
{
// heapify from the root down
int current = 0; // root node
while (true)
{
int left = 2 * current + 1;
int right = left + 1;
// Find the smallest value of (parent, left child, right child)
int smallest = current;
if ((left < heap_size) && (heap[left].score < heap[smallest].score))
{
smallest = left;
}
if ((right < heap_size) && (heap[right].score < heap[smallest].score))
{
smallest = right;
}
if (smallest == current)
{
break;
}
// Exchange the current node with the smallest child and move down to it
ftx_candidate_t tmp = heap[smallest];
heap[smallest] = heap[current];
heap[current] = tmp;
current = smallest;
}
}
static void heapify_up(ftx_candidate_t heap[], int heap_size)
{
// heapify from the last node up
int current = heap_size - 1;
while (current > 0)
{
int parent = (current - 1) / 2;
if (!(heap[current].score < heap[parent].score))
{
break;
}
// Exchange the current node with its parent and move up
ftx_candidate_t tmp = heap[parent];
heap[parent] = heap[current];
heap[current] = tmp;
current = parent;
}
}
// Compute unnormalized log likelihood log(p(1) / p(0)) of 2 message bits (1 FSK symbol)
static void ft4_extract_symbol(const WF_ELEM_T* wf, float* logl)
{
// Cleaned up code for the simple case of n_syms==1
float s2[4];
for (int j = 0; j < 4; ++j)
{
s2[j] = WF_ELEM_MAG(wf[kFT4_Gray_map[j]]);
}
logl[0] = max2(s2[2], s2[3]) - max2(s2[0], s2[1]);
logl[1] = max2(s2[1], s2[3]) - max2(s2[0], s2[2]);
}
static void ft2_extract_logl_sequence(const float complex symbols[4][FT2_NN - FT2_NR], int start_sym, int n_syms, float* metrics)
{
const int n_bits = 2 * n_syms;
const int n_sequences = 1 << n_bits;
for (int bit = 0; bit < n_bits; ++bit)
{
float max_zero = -INFINITY;
float max_one = -INFINITY;
for (int seq = 0; seq < n_sequences; ++seq)
{
float complex sum = 0.0f;
for (int sym = 0; sym < n_syms; ++sym)
{
int shift = 2 * (n_syms - sym - 1);
int dibit = (seq >> shift) & 0x3;
int tone = kFT4_Gray_map[dibit];
sum += symbols[tone][start_sym + sym];
}
float strength = cabsf(sum);
int mask_bit = n_bits - bit - 1;
if (((seq >> mask_bit) & 0x1) != 0)
{
if (strength > max_one)
max_one = strength;
}
else
{
if (strength > max_zero)
max_zero = strength;
}
}
metrics[bit] = max_one - max_zero;
}
}
// Compute unnormalized log likelihood log(p(1) / p(0)) of 3 message bits (1 FSK symbol)
static void ft8_extract_symbol(const WF_ELEM_T* wf, float* logl)
{
// Cleaned up code for the simple case of n_syms==1
#if 1
float s2[8];
for (int j = 0; j < 8; ++j)
{
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j]]);
}
logl[0] = max4(s2[4], s2[5], s2[6], s2[7]) - max4(s2[0], s2[1], s2[2], s2[3]);
logl[1] = max4(s2[2], s2[3], s2[6], s2[7]) - max4(s2[0], s2[1], s2[4], s2[5]);
logl[2] = max4(s2[1], s2[3], s2[5], s2[7]) - max4(s2[0], s2[2], s2[4], s2[6]);
#else
float a[7] = {
// (float)wf[7] - (float)wf[0], // 0: p(111) / p(000)
(float)wf[5] - (float)wf[2], // 0: p(100) / p(011)
(float)wf[3] - (float)wf[0], // 1: p(010) / p(000)
(float)wf[6] - (float)wf[3], // 2: p(101) / p(010)
(float)wf[6] - (float)wf[2], // 3: p(101) / p(011)
(float)wf[7] - (float)wf[4], // 4: p(111) / p(110)
(float)wf[4] - (float)wf[1], // 5: p(110) / p(001)
(float)wf[5] - (float)wf[1] // 6: p(100) / p(001)
};
float k = 1.0f;
// logl[0] = k * (a[0] + a[2] + a[3] + a[5] + a[6]) / 5;
// logl[1] = k * (a[0] / 4 + (a[1] - a[3]) * 5 / 24 + (a[5] - a[2]) / 6 + (a[4] - a[6]) / 24);
// logl[2] = k * (a[0] / 4 + (a[1] - a[3]) / 24 + (a[2] - a[5]) / 6 + (a[4] - a[6]) * 5 / 24);
logl[0] = k * (a[1] / 6 + a[2] / 3 + a[3] / 6 + a[4] / 6 + a[5] / 3 + a[6] / 6);
logl[1] = k * (-a[0] / 4 + a[1] * 7 / 24 + (a[4] - a[3]) / 8 + a[5] / 3 + a[6] / 24);
logl[2] = k * (-a[0] / 4 + (a[1] - a[6]) / 8 + a[2] / 3 + a[3] / 24 + a[4] * 7 / 24 - a[5] * 5 / 18);
#endif
// for (int i = 0; i < 8; ++i)
// printf("%d ", WF_ELEM_MAG_INT(wf[i]));
// for (int i = 0; i < 3; ++i)
// printf("%.1f ", logl[i]);
// printf("\n");
}
// Compute unnormalized log likelihood log(p(1) / p(0)) of bits corresponding to several FSK symbols at once
static void ft8_decode_multi_symbols(const WF_ELEM_T* wf, int num_bins, int n_syms, int bit_idx, float* log174)
{
const int n_bits = 3 * n_syms;
const int n_tones = (1 << n_bits);
float s2[n_tones];
for (int j = 0; j < n_tones; ++j)
{
int j1 = j & 0x07;
if (n_syms == 1)
{
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j1]]);
continue;
}
int j2 = (j >> 3) & 0x07;
if (n_syms == 2)
{
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j2]]);
s2[j] += WF_ELEM_MAG(wf[kFT8_Gray_map[j1] + 4 * num_bins]);
continue;
}
int j3 = (j >> 6) & 0x07;
s2[j] = WF_ELEM_MAG(wf[kFT8_Gray_map[j3]]);
s2[j] += WF_ELEM_MAG(wf[kFT8_Gray_map[j2] + 4 * num_bins]);
s2[j] += WF_ELEM_MAG(wf[kFT8_Gray_map[j1] + 8 * num_bins]);
}
// Extract bit significance (and convert them to float)
// 8 FSK tones = 3 bits
for (int i = 0; i < n_bits; ++i)
{
if (bit_idx + i >= FTX_LDPC_N)
{
// Respect array size
break;
}
uint16_t mask = (n_tones >> (i + 1));
float max_zero = -1000, max_one = -1000;
for (int n = 0; n < n_tones; ++n)
{
if (n & mask)
{
max_one = max2(max_one, s2[n]);
}
else
{
max_zero = max2(max_zero, s2[n]);
}
}
log174[bit_idx + i] = max_one - max_zero;
}
}
// Packs a string of bits each represented as a zero/non-zero byte in plain[],
// as a string of packed bits starting from the MSB of the first byte of packed[]
static void pack_bits(const uint8_t bit_array[], int num_bits, uint8_t packed[])
{
int num_bytes = (num_bits + 7) / 8;
for (int i = 0; i < num_bytes; ++i)
{
packed[i] = 0;
}
uint8_t mask = 0x80;
int byte_idx = 0;
for (int i = 0; i < num_bits; ++i)
{
if (bit_array[i])
{
packed[byte_idx] |= mask;
}
mask >>= 1;
if (!mask)
{
mask = 0x80;
++byte_idx;
}
}
}
+96
View File
@@ -0,0 +1,96 @@
#ifndef _INCLUDE_DECODE_H_
#define _INCLUDE_DECODE_H_
#include <stdint.h>
#include <stdbool.h>
#include "constants.h"
#include "message.h"
#ifdef __cplusplus
extern "C"
{
#endif
typedef struct
{
float mag;
float phase;
} waterfall_cpx_t;
#define WATERFALL_USE_PHASE
#ifdef WATERFALL_USE_PHASE
#define WF_ELEM_T waterfall_cpx_t
#define WF_ELEM_MAG(x) ((x).mag)
#define WF_ELEM_MAG_INT(x) (int)(2 * ((x).mag + 120.0f))
#else
#define WF_ELEM_T uint8_t
#define WF_ELEM_MAG(x) ((float)(x)*0.5f - 120.0f)
#define WF_ELEM_MAG_INT(x) (int)(x)
#endif
/// Input structure to ftx_find_sync() function. This structure describes stored waterfall data over the whole message slot.
/// Fields time_osr and freq_osr specify additional oversampling rate for time and frequency resolution.
/// If time_osr=1, FFT magnitude data is collected once for every symbol transmitted, i.e. every 1/6.25 = 0.16 seconds.
/// Values time_osr > 1 mean each symbol is further subdivided in time.
/// If freq_osr=1, each bin in the FFT magnitude data corresponds to 6.25 Hz, which is the tone spacing.
/// Values freq_osr > 1 mean the tone spacing is further subdivided by FFT analysis.
typedef struct
{
int max_blocks; ///< number of blocks (symbols) allocated in the mag array
int num_blocks; ///< number of blocks (symbols) stored in the mag array
int num_bins; ///< number of FFT bins in terms of 6.25 Hz
int time_osr; ///< number of time subdivisions
int freq_osr; ///< number of frequency subdivisions
WF_ELEM_T* mag; ///< FFT magnitudes stored as uint8_t[blocks][time_osr][freq_osr][num_bins]
int block_stride; ///< Helper value = time_osr * freq_osr * num_bins
ftx_protocol_t protocol; ///< Indicate if using FT4 or FT8
} ftx_waterfall_t;
/// Output structure of ftx_find_sync() and input structure of ftx_decode().
/// Holds the position of potential start of a message in time and frequency.
typedef struct
{
int16_t score; ///< Candidate score (non-negative number; higher score means higher likelihood)
int16_t time_offset; ///< Index of the time block
int16_t freq_offset; ///< Index of the frequency bin
uint8_t time_sub; ///< Index of the time subdivision used
uint8_t freq_sub; ///< Index of the frequency subdivision used
} ftx_candidate_t;
/// Structure that contains the status of various steps during decoding of a message
typedef struct
{
float freq;
float time;
int ldpc_errors; ///< Number of LDPC errors during decoding
uint16_t crc_extracted; ///< CRC value recovered from the message
uint16_t crc_calculated; ///< CRC value calculated over the payload
// int unpack_status; ///< Return value of the unpack routine
} ftx_decode_status_t;
/// Localize top N candidates in frequency and time according to their sync strength (looking at Costas symbols)
/// We treat and organize the candidate list as a min-heap (empty initially).
/// @param[in] power Waterfall data collected during message slot
/// @param[in] sync_pattern Synchronization pattern
/// @param[in] num_candidates Number of maximum candidates (size of heap array)
/// @param[in,out] heap Array of ftx_candidate_t type entries (with num_candidates allocated entries)
/// @param[in] min_score Minimal score allowed for pruning unlikely candidates (can be zero for no effect)
/// @return Number of candidates filled in the heap
int ftx_find_candidates(const ftx_waterfall_t* power, int num_candidates, ftx_candidate_t heap[], int min_score);
/// Attempt to decode a message candidate. Extracts the bit probabilities, runs LDPC decoder, checks CRC and unpacks the message in plain text.
/// @param[in] power Waterfall data collected during message slot
/// @param[in] cand Candidate to decode
/// @param[in] max_iterations Maximum allowed LDPC iterations (lower number means faster decode, but less precise)
/// @param[out] message ftx_message_t structure that will receive the decoded message
/// @param[out] status ftx_decode_status_t structure that will be filled with the status of various decoding steps
/// @return True if the decoding was successful, false otherwise (check status for details)
bool ftx_decode_candidate(const ftx_waterfall_t* power, const ftx_candidate_t* cand, int max_iterations, ftx_message_t* message, ftx_decode_status_t* status);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_DECODE_H_
+200
View File
@@ -0,0 +1,200 @@
#include "encode.h"
#include "constants.h"
#include "crc.h"
#include <stdio.h>
// Returns 1 if an odd number of bits are set in x, zero otherwise
static uint8_t parity8(uint8_t x)
{
x ^= x >> 4; // a b c d ae bf cg dh
x ^= x >> 2; // a b ac bd cae dbf aecg bfdh
x ^= x >> 1; // a ab bac acbd bdcae caedbf aecgbfdh
return x % 2; // modulo 2
}
// Encode via LDPC a 91-bit message and return a 174-bit codeword.
// The generator matrix has dimensions (87,87).
// The code is a (174,91) regular LDPC code with column weight 3.
// Arguments:
// [IN] message - array of 91 bits stored as 12 bytes (MSB first)
// [OUT] codeword - array of 174 bits stored as 22 bytes (MSB first)
static void encode174(const uint8_t* message, uint8_t* codeword)
{
// This implementation accesses the generator bits straight from the packed binary representation in kFTX_LDPC_generator
// Fill the codeword with message and zeros, as we will only update binary ones later
for (int j = 0; j < FTX_LDPC_N_BYTES; ++j)
{
codeword[j] = (j < FTX_LDPC_K_BYTES) ? message[j] : 0;
}
// Compute the byte index and bit mask for the first checksum bit
uint8_t col_mask = (0x80u >> (FTX_LDPC_K % 8u)); // bitmask of current byte
uint8_t col_idx = FTX_LDPC_K_BYTES - 1; // index into byte array
// Compute the LDPC checksum bits and store them in codeword
for (int i = 0; i < FTX_LDPC_M; ++i)
{
// Fast implementation of bitwise multiplication and parity checking
// Normally nsum would contain the result of dot product between message and kFTX_LDPC_generator[i],
// but we only compute the sum modulo 2.
uint8_t nsum = 0;
for (int j = 0; j < FTX_LDPC_K_BYTES; ++j)
{
uint8_t bits = message[j] & kFTX_LDPC_generator[i][j]; // bitwise AND (bitwise multiplication)
nsum ^= parity8(bits); // bitwise XOR (addition modulo 2)
}
// Set the current checksum bit in codeword if nsum is odd
if (nsum % 2)
{
codeword[col_idx] |= col_mask;
}
// Update the byte index and bit mask for the next checksum bit
col_mask >>= 1;
if (col_mask == 0)
{
col_mask = 0x80u;
++col_idx;
}
}
}
void ft8_encode(const uint8_t* payload, uint8_t* tones)
{
uint8_t a91[FTX_LDPC_K_BYTES]; // Store 77 bits of payload + 14 bits CRC
// Compute and add CRC at the end of the message
// a91 contains 77 bits of payload + 14 bits of CRC
ftx_add_crc(payload, a91);
uint8_t codeword[FTX_LDPC_N_BYTES];
encode174(a91, codeword);
// Message structure: S7 D29 S7 D29 S7
// Total symbols: 79 (FT8_NN)
uint8_t mask = 0x80u; // Mask to extract 1 bit from codeword
int i_byte = 0; // Index of the current byte of the codeword
for (int i_tone = 0; i_tone < FT8_NN; ++i_tone)
{
if ((i_tone >= 0) && (i_tone < 7))
{
tones[i_tone] = kFT8_Costas_pattern[i_tone];
}
else if ((i_tone >= 36) && (i_tone < 43))
{
tones[i_tone] = kFT8_Costas_pattern[i_tone - 36];
}
else if ((i_tone >= 72) && (i_tone < 79))
{
tones[i_tone] = kFT8_Costas_pattern[i_tone - 72];
}
else
{
// Extract 3 bits from codeword at i-th position
uint8_t bits3 = 0;
if (codeword[i_byte] & mask)
bits3 |= 4;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
if (codeword[i_byte] & mask)
bits3 |= 2;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
if (codeword[i_byte] & mask)
bits3 |= 1;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
tones[i_tone] = kFT8_Gray_map[bits3];
}
}
}
void ft4_encode(const uint8_t* payload, uint8_t* tones)
{
uint8_t a91[FTX_LDPC_K_BYTES]; // Store 77 bits of payload + 14 bits CRC
uint8_t payload_xor[10]; // Encoded payload data
// '[..] for FT4 only, in order to avoid transmitting a long string of zeros when sending CQ messages,
// the assembled 77-bit message is bitwise exclusive-ORed with [a] pseudorandom sequence before computing the CRC and FEC parity bits'
for (int i = 0; i < 10; ++i)
{
payload_xor[i] = payload[i] ^ kFT4_XOR_sequence[i];
}
// Compute and add CRC at the end of the message
// a91 contains 77 bits of payload + 14 bits of CRC
ftx_add_crc(payload_xor, a91);
uint8_t codeword[FTX_LDPC_N_BYTES];
encode174(a91, codeword); // 91 bits -> 174 bits
// Message structure: R S4_1 D29 S4_2 D29 S4_3 D29 S4_4 R
// Total symbols: 105 (FT4_NN)
uint8_t mask = 0x80u; // Mask to extract 1 bit from codeword
int i_byte = 0; // Index of the current byte of the codeword
for (int i_tone = 0; i_tone < FT4_NN; ++i_tone)
{
if ((i_tone == 0) || (i_tone == 104))
{
tones[i_tone] = 0; // R (ramp) symbol
}
else if ((i_tone >= 1) && (i_tone < 5))
{
tones[i_tone] = kFT4_Costas_pattern[0][i_tone - 1];
}
else if ((i_tone >= 34) && (i_tone < 38))
{
tones[i_tone] = kFT4_Costas_pattern[1][i_tone - 34];
}
else if ((i_tone >= 67) && (i_tone < 71))
{
tones[i_tone] = kFT4_Costas_pattern[2][i_tone - 67];
}
else if ((i_tone >= 100) && (i_tone < 104))
{
tones[i_tone] = kFT4_Costas_pattern[3][i_tone - 100];
}
else
{
// Extract 2 bits from codeword at i-th position
uint8_t bits2 = 0;
if (codeword[i_byte] & mask)
bits2 |= 2;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
if (codeword[i_byte] & mask)
bits2 |= 1;
if (0 == (mask >>= 1))
{
mask = 0x80u;
i_byte++;
}
tones[i_tone] = kFT4_Gray_map[bits2];
}
}
}
void ft2_encode(const uint8_t* payload, uint8_t* tones)
{
ft4_encode(payload, tones);
}
+47
View File
@@ -0,0 +1,47 @@
#ifndef _INCLUDE_ENCODE_H_
#define _INCLUDE_ENCODE_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
// typedef struct
// {
// uint8_t tones[FT8_NN];
// // for waveform readout:
// int n_spsym; // Number of waveform samples per symbol
// float *pulse; // [3 * n_spsym]
// int idx_symbol; // Index of the current symbol
// float f0; // Base frequency, Hertz
// float signal_rate; // Waveform sample rate, Hertz
// } encoder_t;
// void encoder_init(float signal_rate, float *pulse_buffer);
// void encoder_set_f0(float f0);
// void encoder_process(const message_t *message); // in: message
// void encoder_generate(float *block); // out: block of waveforms
/// Generate FT8 tone sequence from payload data
/// @param[in] payload - 10 byte array consisting of 77 bit payload
/// @param[out] tones - array of FT8_NN (79) bytes to store the generated tones (encoded as 0..7)
void ft8_encode(const uint8_t* payload, uint8_t* tones);
/// Generate FT4 tone sequence from payload data
/// @param[in] payload - 10 byte array consisting of 77 bit payload
/// @param[out] tones - array of FT4_NN (105) bytes to store the generated tones (encoded as 0..3)
void ft4_encode(const uint8_t* payload, uint8_t* tones);
/// Generate FT2 tone sequence from payload data.
/// FT2 uses the FT4 framing with a doubled symbol rate.
/// @param[in] payload - 10 byte array consisting of 77 bit payload
/// @param[out] tones - array of FT2_NN (105) bytes to store the generated tones (encoded as 0..3)
void ft2_encode(const uint8_t* payload, uint8_t* tones);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_ENCODE_H_
+251
View File
@@ -0,0 +1,251 @@
//
// LDPC decoder for FT8.
//
// given a 174-bit codeword as an array of log-likelihood of zero,
// return a 174-bit corrected codeword, or zero-length array.
// last 87 bits are the (systematic) plain-text.
// this is an implementation of the sum-product algorithm
// from Sarah Johnson's Iterative Error Correction book.
// codeword[i] = log ( P(x=0) / P(x=1) )
//
#include "ldpc.h"
#include "constants.h"
#include <stdio.h>
#include <math.h>
#include <stdlib.h>
#include <stdbool.h>
static int ldpc_check(uint8_t codeword[]);
static float fast_tanh(float x);
static float fast_atanh(float x);
// codeword is 174 log-likelihoods.
// plain is a return value, 174 ints, to be 0 or 1.
// max_iters is how hard to try.
// ok == 87 means success.
void ldpc_decode(float codeword[], int max_iters, uint8_t plain[], int* ok)
{
float m[FTX_LDPC_M][FTX_LDPC_N]; // ~60 kB
float e[FTX_LDPC_M][FTX_LDPC_N]; // ~60 kB
int min_errors = FTX_LDPC_M;
for (int j = 0; j < FTX_LDPC_M; j++)
{
for (int i = 0; i < FTX_LDPC_N; i++)
{
m[j][i] = codeword[i];
e[j][i] = 0.0f;
}
}
for (int iter = 0; iter < max_iters; iter++)
{
for (int j = 0; j < FTX_LDPC_M; j++)
{
for (int ii1 = 0; ii1 < kFTX_LDPC_Num_rows[j]; ii1++)
{
int i1 = kFTX_LDPC_Nm[j][ii1] - 1;
float a = 1.0f;
for (int ii2 = 0; ii2 < kFTX_LDPC_Num_rows[j]; ii2++)
{
int i2 = kFTX_LDPC_Nm[j][ii2] - 1;
if (i2 != i1)
{
a *= fast_tanh(-m[j][i2] / 2.0f);
}
}
e[j][i1] = -2.0f * fast_atanh(a);
}
}
for (int i = 0; i < FTX_LDPC_N; i++)
{
float l = codeword[i];
for (int j = 0; j < 3; j++)
l += e[kFTX_LDPC_Mn[i][j] - 1][i];
plain[i] = (l > 0) ? 1 : 0;
}
int errors = ldpc_check(plain);
if (errors < min_errors)
{
// Update the current best result
min_errors = errors;
if (errors == 0)
{
break; // Found a perfect answer
}
}
for (int i = 0; i < FTX_LDPC_N; i++)
{
for (int ji1 = 0; ji1 < 3; ji1++)
{
int j1 = kFTX_LDPC_Mn[i][ji1] - 1;
float l = codeword[i];
for (int ji2 = 0; ji2 < 3; ji2++)
{
if (ji1 != ji2)
{
int j2 = kFTX_LDPC_Mn[i][ji2] - 1;
l += e[j2][i];
}
}
m[j1][i] = l;
}
}
}
*ok = min_errors;
}
//
// does a 174-bit codeword pass the FT8's LDPC parity checks?
// returns the number of parity errors.
// 0 means total success.
//
static int ldpc_check(uint8_t codeword[])
{
int errors = 0;
for (int m = 0; m < FTX_LDPC_M; ++m)
{
uint8_t x = 0;
for (int i = 0; i < kFTX_LDPC_Num_rows[m]; ++i)
{
x ^= codeword[kFTX_LDPC_Nm[m][i] - 1];
}
if (x != 0)
{
++errors;
}
}
return errors;
}
void bp_decode(float codeword[], int max_iters, uint8_t plain[], int* ok)
{
float tov[FTX_LDPC_N][3];
float toc[FTX_LDPC_M][7];
int min_errors = FTX_LDPC_M;
// initialize message data
for (int n = 0; n < FTX_LDPC_N; ++n)
{
tov[n][0] = tov[n][1] = tov[n][2] = 0;
}
for (int iter = 0; iter < max_iters; ++iter)
{
// Do a hard decision guess (tov=0 in iter 0)
int plain_sum = 0;
for (int n = 0; n < FTX_LDPC_N; ++n)
{
plain[n] = ((codeword[n] + tov[n][0] + tov[n][1] + tov[n][2]) > 0) ? 1 : 0;
plain_sum += plain[n];
}
if (plain_sum == 0)
{
// message converged to all-zeros, which is prohibited
break;
}
// Check to see if we have a codeword (check before we do any iter)
int errors = ldpc_check(plain);
if (errors < min_errors)
{
// we have a better guess - update the result
min_errors = errors;
if (errors == 0)
{
break; // Found a perfect answer
}
}
// Send messages from bits to check nodes
for (int m = 0; m < FTX_LDPC_M; ++m)
{
for (int n_idx = 0; n_idx < kFTX_LDPC_Num_rows[m]; ++n_idx)
{
int n = kFTX_LDPC_Nm[m][n_idx] - 1;
// for each (n, m)
float Tnm = codeword[n];
for (int m_idx = 0; m_idx < 3; ++m_idx)
{
if ((kFTX_LDPC_Mn[n][m_idx] - 1) != m)
{
Tnm += tov[n][m_idx];
}
}
toc[m][n_idx] = fast_tanh(-Tnm / 2);
}
}
// send messages from check nodes to variable nodes
for (int n = 0; n < FTX_LDPC_N; ++n)
{
for (int m_idx = 0; m_idx < 3; ++m_idx)
{
int m = kFTX_LDPC_Mn[n][m_idx] - 1;
// for each (n, m)
float Tmn = 1.0f;
for (int n_idx = 0; n_idx < kFTX_LDPC_Num_rows[m]; ++n_idx)
{
if ((kFTX_LDPC_Nm[m][n_idx] - 1) != n)
{
Tmn *= toc[m][n_idx];
}
}
tov[n][m_idx] = -2 * fast_atanh(Tmn);
}
}
}
*ok = min_errors;
}
// Ideas for approximating tanh/atanh:
// * https://varietyofsound.wordpress.com/2011/02/14/efficient-tanh-computation-using-lamberts-continued-fraction/
// * http://functions.wolfram.com/ElementaryFunctions/ArcTanh/10/0001/
// * https://mathr.co.uk/blog/2017-09-06_approximating_hyperbolic_tangent.html
// * https://math.stackexchange.com/a/446411
static float fast_tanh(float x)
{
if (x < -4.97f)
{
return -1.0f;
}
if (x > 4.97f)
{
return 1.0f;
}
float x2 = x * x;
// float a = x * (135135.0f + x2 * (17325.0f + x2 * (378.0f + x2)));
// float b = 135135.0f + x2 * (62370.0f + x2 * (3150.0f + x2 * 28.0f));
// float a = x * (10395.0f + x2 * (1260.0f + x2 * 21.0f));
// float b = 10395.0f + x2 * (4725.0f + x2 * (210.0f + x2));
float a = x * (945.0f + x2 * (105.0f + x2));
float b = 945.0f + x2 * (420.0f + x2 * 15.0f);
return a / b;
}
static float fast_atanh(float x)
{
float x2 = x * x;
// float a = x * (-15015.0f + x2 * (19250.0f + x2 * (-5943.0f + x2 * 256.0f)));
// float b = (-15015.0f + x2 * (24255.0f + x2 * (-11025.0f + x2 * 1225.0f)));
// float a = x * (-1155.0f + x2 * (1190.0f + x2 * -231.0f));
// float b = (-1155.0f + x2 * (1575.0f + x2 * (-525.0f + x2 * 25.0f)));
float a = x * (945.0f + x2 * (-735.0f + x2 * 64.0f));
float b = (945.0f + x2 * (-1050.0f + x2 * 225.0f));
return a / b;
}
+23
View File
@@ -0,0 +1,23 @@
#ifndef _INCLUDE_LDPC_H_
#define _INCLUDE_LDPC_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
// codeword is 174 log-likelihoods.
// plain is a return value, 174 ints, to be 0 or 1.
// iters is how hard to try.
// ok == 87 means success.
void ldpc_decode(float codeword[], int max_iters, uint8_t plain[], int* ok);
void bp_decode(float codeword[], int max_iters, uint8_t plain[], int* ok);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_LDPC_H_
+1156
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
#ifndef _INCLUDE_MESSAGE_H_
#define _INCLUDE_MESSAGE_H_
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C"
{
#endif
#define FTX_PAYLOAD_LENGTH_BYTES 10 ///< number of bytes to hold 77 bits of FTx payload data
#define FTX_MAX_MESSAGE_LENGTH 35 ///< max message length = callsign[13] + space + callsign[13] + space + report[6] + terminator
#define FTX_MAX_MESSAGE_FIELDS 3 // may need to get longer for multi-part messages (DXpedition, contest etc.)
/// Structure that holds the decoded message
typedef struct
{
uint8_t payload[FTX_PAYLOAD_LENGTH_BYTES];
uint16_t hash; ///< Hash value to be used in hash table and quick checking for duplicates
} ftx_message_t;
// ----------------------------------------------------------------------------------
// i3.n3 Example message Bits Total Purpose
// ----------------------------------------------------------------------------------
// 0.0 FREE TEXT MSG 71 71 Free text
// 0.1 K1ABC RR73; W9XYZ <KH1/KH7Z> -12 28 28 10 5 71 DXpedition Mode
// 0.2 PA3XYZ/P R 590003 IO91NP 28 1 1 3 12 25 70 EU VHF contest
// 0.3 WA9XYZ KA1ABC R 16A EMA 28 28 1 4 3 7 71 ARRL Field Day
// 0.4 WA9XYZ KA1ABC R 32A EMA 28 28 1 4 3 7 71 ARRL Field Day
// 0.5 123456789ABCDEF012 71 71 Telemetry (18 hex)
// 0.6 K1ABC RR73; CQ W9XYZ EN37 28 28 15 71 Contesting
// 0.7 ... tbd
// 1 WA9XYZ/R KA1ABC/R R FN42 28 1 28 1 1 15 74 Standard msg
// 2 PA3XYZ/P GM4ABC/P R JO22 28 1 28 1 1 15 74 EU VHF contest
// 3 TU; W9XYZ K1ABC R 579 MA 1 28 28 1 3 13 74 ARRL RTTY Roundup
// 4 <WA9XYZ> PJ4/KA1ABC RR73 12 58 1 2 1 74 Nonstandard calls
// 5 TU; W9XYZ K1ABC R-07 FN 1 28 28 1 7 9 74 WWROF contest ?
typedef enum
{
FTX_MESSAGE_TYPE_FREE_TEXT, // 0.0 FREE TEXT MSG 71 71 Free text
FTX_MESSAGE_TYPE_DXPEDITION, // 0.1 K1ABC RR73; W9XYZ <KH1/KH7Z> -12 28 28 10 5 71 DXpedition Mode
FTX_MESSAGE_TYPE_EU_VHF, // 0.2 PA3XYZ/P R 590003 IO91NP 28 1 1 3 12 25 70 EU VHF contest
FTX_MESSAGE_TYPE_ARRL_FD, // 0.3 WA9XYZ KA1ABC R 16A EMA 28 28 1 4 3 7 71 ARRL Field Day
// 0.4 WA9XYZ KA1ABC R 32A EMA 28 28 1 4 3 7 71 ARRL Field Day
FTX_MESSAGE_TYPE_TELEMETRY, // 0.5 0123456789abcdef01 71 71 Telemetry (18 hex)
FTX_MESSAGE_TYPE_CONTESTING, // 0.6 K1ABC RR73; CQ W9XYZ EN37 28 28 15 71 Contesting
FTX_MESSAGE_TYPE_STANDARD, // 1 WA9XYZ/R KA1ABC/R R FN42 28 1 28 1 1 15 74 Standard msg
// 2 PA3XYZ/P GM4ABC/P R JO22 28 1 28 1 1 15 74 EU VHF contest
FTX_MESSAGE_TYPE_ARRL_RTTY, // 3 TU; W9XYZ K1ABC R 579 MA 1 28 28 1 3 13 74 ARRL RTTY Roundup
FTX_MESSAGE_TYPE_NONSTD_CALL, // 4 <WA9XYZ> PJ4/KA1ABC RR73 12 58 1 2 1 74 Nonstandard calls
FTX_MESSAGE_TYPE_WWROF, // 5 TU; W9XYZ K1ABC R-07 FN 1 28 28 1 7 9 74 WWROF contest ?
FTX_MESSAGE_TYPE_UNKNOWN // Unknown or invalid type
} ftx_message_type_t;
typedef enum
{
FTX_CALLSIGN_HASH_22_BITS,
FTX_CALLSIGN_HASH_12_BITS,
FTX_CALLSIGN_HASH_10_BITS
} ftx_callsign_hash_type_t;
typedef struct
{
/// Called when a callsign is looked up by its 22/12/10 bit hash code
bool (*lookup_hash)(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign);
/// Called when a callsign should hashed and stored (by its 22, 12 and 10 bit hash codes)
void (*save_hash)(const char* callsign, uint32_t n22);
} ftx_callsign_hash_interface_t;
typedef enum
{
FTX_MESSAGE_RC_OK,
FTX_MESSAGE_RC_ERROR_CALLSIGN1,
FTX_MESSAGE_RC_ERROR_CALLSIGN2,
FTX_MESSAGE_RC_ERROR_SUFFIX,
FTX_MESSAGE_RC_ERROR_GRID,
FTX_MESSAGE_RC_ERROR_TYPE
} ftx_message_rc_t;
typedef enum
{
FTX_FIELD_UNKNOWN,
FTX_FIELD_NONE,
FTX_FIELD_TOKEN, // RRR, RR73, 73, DE, QRZ, CQ, ...
FTX_FIELD_TOKEN_WITH_ARG, // CQ nnn, CQ abcd
FTX_FIELD_CALL,
FTX_FIELD_GRID,
FTX_FIELD_RST
} ftx_field_t;
typedef struct
{
// parallel arrays:
// e.g. "CQ POTA W9XYZ AB12" generates
// types { FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_CALL, FTX_FIELD_CALL_GRID" }
// offsets { 0, 8, 14 }
// Both arrays end where offsets[i] < 0
ftx_field_t types[FTX_MAX_MESSAGE_FIELDS];
int16_t offsets[FTX_MAX_MESSAGE_FIELDS];
} ftx_message_offsets_t;
// Callsign types and sizes:
// * Std. call (basecall) - 1-2 letter/digit prefix (at least one letter), 1 digit area code, 1-3 letter suffix,
// total 3-6 chars (exception: 7 character calls with prefixes 3DA0- and 3XA..3XZ-)
// * Ext. std. call - basecall followed by /R or /P
// * Nonstd. call - all the rest, limited to 3-11 characters either alphanumeric or stroke (/)
// In case a call is looked up from its hash value, the call is enclosed in angular brackets (<CA0LL>).
void ftx_message_init(ftx_message_t* msg);
uint8_t ftx_message_get_i3(const ftx_message_t* msg);
uint8_t ftx_message_get_n3(const ftx_message_t* msg);
ftx_message_type_t ftx_message_get_type(const ftx_message_t* msg);
// bool ftx_message_check_recipient(const ftx_message_t* msg, const char* callsign);
/// Pack (encode) a callsign in the standard way, and return the numeric representation.
/// Returns -1 if \a callsign cannot be encoded in the standard way.
/// This function can be used to decide whether to call ftx_message_encode_std() or ftx_message_decode_nonstd().
/// Alternatively, ftx_message_encode_std() itself fails when one of the callsigns cannot be packed this way.
int32_t pack_basecall(const char* callsign, int length);
/// Pack (encode) a text message, guessing which message type to use and falling back on failure:
/// if there are 3 or fewer tokens, try ftx_message_encode_std first,
/// then ftx_message_encode_nonstd if that fails because of a non-standard callsign;
/// otherwise fall back to ftx_message_encode_free.
/// If you already know which type to use, you can call one of those functions directly.
ftx_message_rc_t ftx_message_encode(ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, const char* message_text);
/// Pack Type 1 (Standard 77-bit message) or Type 2 (ditto, with a "/P" call) message
/// Rules of callsign validity:
/// - call_to can be 'DE', 'CQ', 'QRZ', 'CQ_nnn' (three digits), or 'CQ_abcd' (four letters)
/// - nonstandard calls within <> brackets are allowed, if they don't contain '/'
ftx_message_rc_t ftx_message_encode_std(ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, const char* call_to, const char* call_de, const char* extra);
/// Pack Type 4 (One nonstandard call and one hashed call) message
ftx_message_rc_t ftx_message_encode_nonstd(ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, const char* call_to, const char* call_de, const char* extra);
/// Pack plain text, up to 13 characters
ftx_message_rc_t ftx_message_encode_free(ftx_message_t* msg, const char* text);
ftx_message_rc_t ftx_message_encode_telemetry(ftx_message_t* msg, const uint8_t* telemetry);
ftx_message_rc_t ftx_message_decode(const ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, char* message, ftx_message_offsets_t* offsets);
ftx_message_rc_t ftx_message_decode_std(const ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, char* call_to, char* call_de, char* extra, ftx_field_t field_types[FTX_MAX_MESSAGE_FIELDS]);
ftx_message_rc_t ftx_message_decode_nonstd(const ftx_message_t* msg, ftx_callsign_hash_interface_t* hash_if, char* call_to, char* call_de, char* extra, ftx_field_t field_types[FTX_MAX_MESSAGE_FIELDS]);
void ftx_message_decode_free(const ftx_message_t* msg, char* text);
void ftx_message_decode_telemetry_hex(const ftx_message_t* msg, char* telemetry_hex);
void ftx_message_decode_telemetry(const ftx_message_t* msg, uint8_t* telemetry);
#ifdef FTX_DEBUG_PRINT
void ftx_message_print(ftx_message_t* msg);
#endif
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_MESSAGE_H_
+303
View File
@@ -0,0 +1,303 @@
#include "text.h"
#include <string.h>
const char* trim_front(const char* str, char to_trim)
{
// Skip leading to_trim characters
while (*str == to_trim)
{
str++;
}
return str;
}
void trim_back(char* str, char to_trim)
{
// Skip trailing to_trim characters by replacing them with '\0' characters
int idx = strlen(str) - 1;
while (idx >= 0 && str[idx] == to_trim)
{
str[idx--] = '\0';
}
}
char* trim(char* str)
{
str = (char*)trim_front(str, ' ');
trim_back(str, ' ');
// return a pointer to the first non-whitespace character
return str;
}
char* trim_brackets(char* str)
{
str = (char*)trim_front(str, '<');
trim_back(str, '>');
// return a pointer to the first non-whitespace character
return str;
}
void trim_copy(char* trimmed, const char* str)
{
str = (char*)trim_front(str, ' ');
int len = strlen(str) - 1;
while (len >= 0 && str[len] == ' ')
{
len--;
}
strncpy(trimmed, str, len + 1);
trimmed[len + 1] = '\0';
}
char to_upper(char c)
{
return (c >= 'a' && c <= 'z') ? (c - 'a' + 'A') : c;
}
bool is_digit(char c)
{
return (c >= '0') && (c <= '9');
}
bool is_letter(char c)
{
return ((c >= 'A') && (c <= 'Z')) || ((c >= 'a') && (c <= 'z'));
}
bool is_space(char c)
{
return (c == ' ');
}
bool in_range(char c, char min, char max)
{
return (c >= min) && (c <= max);
}
bool starts_with(const char* string, const char* prefix)
{
return 0 == memcmp(string, prefix, strlen(prefix));
}
bool ends_with(const char* string, const char* suffix)
{
int pos = strlen(string) - strlen(suffix);
if (pos >= 0)
{
return 0 == memcmp(string + pos, suffix, strlen(suffix));
}
return false;
}
bool equals(const char* string1, const char* string2)
{
return 0 == strcmp(string1, string2);
}
// Text message formatting:
// - replaces lowercase letters with uppercase
// - merges consecutive spaces into single space
void fmtmsg(char* msg_out, const char* msg_in)
{
char c;
char last_out = 0;
while ((c = *msg_in))
{
if (c != ' ' || last_out != ' ')
{
last_out = to_upper(c);
*msg_out = last_out;
++msg_out;
}
++msg_in;
}
*msg_out = 0; // Add zero termination
}
// Returns pointer to the null terminator within the given string (destination)
char* append_string(char* string, const char* token)
{
while (*token != '\0')
{
*string = *token;
string++;
token++;
}
*string = '\0';
return string;
}
const char* copy_token(char* token, int length, const char* string)
{
// Copy characters until a whitespace character or the end of string
while (*string != ' ' && *string != '\0')
{
if (length > 1)
{
*token = *string;
token++;
length--;
}
string++;
}
// Fill up the rest of token with \0 terminators
while (length > 0)
{
*token = '\0';
token++;
length--;
}
// Skip whitespace characters
while (*string == ' ')
{
string++;
}
return string;
}
// Parse a 2 digit integer from string
int dd_to_int(const char* str, int length)
{
int result = 0;
bool negative;
int i;
if (str[0] == '-')
{
negative = true;
i = 1; // Consume the - sign
}
else
{
negative = false;
i = (str[0] == '+') ? 1 : 0; // Consume a + sign if found
}
while (i < length)
{
if (str[i] == 0)
break;
if (!is_digit(str[i]))
break;
result *= 10;
result += (str[i] - '0');
++i;
}
return negative ? -result : result;
}
// Convert a 2 digit integer to string
void int_to_dd(char* str, int value, int width, bool full_sign)
{
if (value < 0)
{
*str = '-';
++str;
value = -value;
}
else if (full_sign)
{
*str = '+';
++str;
}
int divisor = 1;
for (int i = 0; i < width - 1; ++i)
{
divisor *= 10;
}
while (divisor >= 1)
{
int digit = value / divisor;
*str = '0' + digit;
++str;
value -= digit * divisor;
divisor /= 10;
}
*str = 0; // Add zero terminator
}
char charn(int c, ft8_char_table_e table)
{
if ((table != FT8_CHAR_TABLE_ALPHANUM) && (table != FT8_CHAR_TABLE_NUMERIC))
{
if (c == 0)
return ' ';
c -= 1;
}
if (table != FT8_CHAR_TABLE_LETTERS_SPACE)
{
if (c < 10)
return '0' + c;
c -= 10;
}
if (table != FT8_CHAR_TABLE_NUMERIC)
{
if (c < 26)
return 'A' + c;
c -= 26;
}
if (table == FT8_CHAR_TABLE_FULL)
{
if (c < 5)
return "+-./?"[c];
}
else if (table == FT8_CHAR_TABLE_ALPHANUM_SPACE_SLASH)
{
if (c == 0)
return '/';
}
return '_'; // unknown character, should never get here
}
// Convert character to its index (charn in reverse) according to a table
int nchar(char c, ft8_char_table_e table)
{
int n = 0;
if ((table != FT8_CHAR_TABLE_ALPHANUM) && (table != FT8_CHAR_TABLE_NUMERIC))
{
if (c == ' ')
return n + 0;
n += 1;
}
if (table != FT8_CHAR_TABLE_LETTERS_SPACE)
{
if (c >= '0' && c <= '9')
return n + (c - '0');
n += 10;
}
if (table != FT8_CHAR_TABLE_NUMERIC)
{
if (c >= 'A' && c <= 'Z')
return n + (c - 'A');
n += 26;
}
if (table == FT8_CHAR_TABLE_FULL)
{
if (c == '+')
return n + 0;
if (c == '-')
return n + 1;
if (c == '.')
return n + 2;
if (c == '/')
return n + 3;
if (c == '?')
return n + 4;
}
else if (table == FT8_CHAR_TABLE_ALPHANUM_SPACE_SLASH)
{
if (c == '/')
return n + 0;
}
// Character not found
return -1;
}
+82
View File
@@ -0,0 +1,82 @@
#ifndef _INCLUDE_TEXT_H_
#define _INCLUDE_TEXT_H_
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
// Utility functions for characters and strings
const char* trim_front(const char* str, char to_trim);
void trim_back(char* str, char to_trim);
/// In-place whitespace trim from front and back:
/// 1) trims the back by changing whitespaces to '\0'
/// 2) trims the front by skipping whitespaces
/// @return trimmed string (pointer to first non-whitespace character)
char* trim(char* str);
/// Trim whitespace from start and end of string
void trim_copy(char* trimmed, const char* str);
/// In-place trim of <> characters from front and back:
/// 1) trims the back by changing > to '\0'
/// 2) trims the front by skipping <
/// @return trimmed string (pointer to first non-whitespace character)
char* trim_brackets(char* str);
char to_upper(char c);
bool is_digit(char c);
bool is_letter(char c);
bool is_space(char c);
bool in_range(char c, char min, char max);
bool starts_with(const char* string, const char* prefix);
bool ends_with(const char* string, const char* suffix);
bool equals(const char* string1, const char* string2);
// Text message formatting:
// - replaces lowercase letters with uppercase
// - merges consecutive spaces into single space
void fmtmsg(char* msg_out, const char* msg_in);
/// Extract and copy a space-delimited token from a string.
/// When the last token has been extracted, the return value points to the terminating zero character.
/// @param[out] token Buffer to receive the extracted token
/// @param[in] length Length of the token buffer (number of characters)
/// @param[in] string Pointer to the string
/// @return Pointer to the next token (can be passed to copy_token to extract the next token)
const char* copy_token(char* token, int length, const char* string);
char* append_string(char* string, const char* token);
// Parse a 2 digit integer from string
int dd_to_int(const char* str, int length);
// Convert a 2 digit integer to string
void int_to_dd(char* str, int value, int width, bool full_sign);
typedef enum
{
FT8_CHAR_TABLE_FULL, // table[42] " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?"
FT8_CHAR_TABLE_ALPHANUM_SPACE_SLASH, // table[38] " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/"
FT8_CHAR_TABLE_ALPHANUM_SPACE, // table[37] " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
FT8_CHAR_TABLE_LETTERS_SPACE, // table[27] " ABCDEFGHIJKLMNOPQRSTUVWXYZ"
FT8_CHAR_TABLE_ALPHANUM, // table[36] "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
FT8_CHAR_TABLE_NUMERIC, // table[10] "0123456789"
} ft8_char_table_e;
/// Convert integer index to ASCII character according to one of character tables
char charn(int c, ft8_char_table_e table);
/// Look up the index of an ASCII character in one of character tables
int nchar(char c, ft8_char_table_e table);
#ifdef __cplusplus
}
#endif
#endif // _INCLUDE_TEXT_H_
+286
View File
@@ -0,0 +1,286 @@
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <math.h>
#include <stdbool.h>
#include "ft8/text.h"
#include "ft8/encode.h"
#include "ft8/constants.h"
#include "fft/kiss_fftr.h"
#include "common/common.h"
#include "ft8/message.h"
#define LOG_LEVEL LOG_INFO
#include "ft8/debug.h"
// void convert_8bit_to_6bit(uint8_t* dst, const uint8_t* src, int nBits)
// {
// // Zero-fill the destination array as we will only be setting bits later
// for (int j = 0; j < (nBits + 5) / 6; ++j)
// {
// dst[j] = 0;
// }
// // Set the relevant bits
// uint8_t mask_src = (1 << 7);
// uint8_t mask_dst = (1 << 5);
// for (int i = 0, j = 0; nBits > 0; --nBits)
// {
// if (src[i] & mask_src)
// {
// dst[j] |= mask_dst;
// }
// mask_src >>= 1;
// if (mask_src == 0)
// {
// mask_src = (1 << 7);
// ++i;
// }
// mask_dst >>= 1;
// if (mask_dst == 0)
// {
// mask_dst = (1 << 5);
// ++j;
// }
// }
// }
/*
bool test1() {
//const char *msg = "CQ DL7ACA JO40"; // 62, 32, 32, 49, 37, 27, 59, 2, 30, 19, 49, 16
const char *msg = "VA3UG F1HMR 73"; // 52, 54, 60, 12, 55, 54, 7, 19, 2, 23, 59, 16
//const char *msg = "RA3Y VE3NLS 73"; // 46, 6, 32, 22, 55, 20, 11, 32, 53, 23, 59, 16
uint8_t a72[9];
int rc = packmsg(msg, a72);
if (rc < 0) return false;
LOG(LOG_INFO, "8-bit packed: ");
for (int i = 0; i < 9; ++i) {
LOG(LOG_INFO, "%02x ", a72[i]);
}
LOG(LOG_INFO, "\n");
uint8_t a72_6bit[12];
convert_8bit_to_6bit(a72_6bit, a72, 72);
LOG(LOG_INFO, "6-bit packed: ");
for (int i = 0; i < 12; ++i) {
LOG(LOG_INFO, "%d ", a72_6bit[i]);
}
LOG(LOG_INFO, "\n");
char msg_out_raw[14];
unpack(a72, msg_out_raw);
char msg_out[14];
fmtmsg(msg_out, msg_out_raw);
LOG(LOG_INFO, "msg_out = [%s]\n", msg_out);
return true;
}
void test2() {
uint8_t test_in[11] = { 0xF1, 0x02, 0x03, 0x04, 0x05, 0x60, 0x70, 0x80, 0x90, 0xA0, 0xFF };
uint8_t test_out[22];
encode174(test_in, test_out);
for (int j = 0; j < 22; ++j) {
LOG(LOG_INFO, "%02x ", test_out[j]);
}
LOG(LOG_INFO, "\n");
}
void test3() {
uint8_t test_in2[10] = { 0x11, 0x00, 0x00, 0x00, 0x00, 0x0E, 0x10, 0x04, 0x01, 0x00 };
uint16_t crc1 = ftx_compute_crc(test_in2, 76); // Calculate CRC of 76 bits only
LOG(LOG_INFO, "CRC: %04x\n", crc1); // should be 0x0708
}
*/
#define CHECK(condition) \
if (!(condition)) \
{ \
printf("FAIL! Condition \'" #condition "' failed\n\n"); \
return; \
}
#define CHECK_EQ_VAL(this, that) \
if ((this) != (that)) \
{ \
printf("FAIL! Expected " #this " (%d) == " #that " (%d)\n\n", \
(this), (that)); \
return; \
}
#define TEST_END printf("Test OK\n\n")
#define CALLSIGN_HASHTABLE_SIZE 256
struct
{
char callsign[12];
uint32_t hash;
} callsign_hashtable[CALLSIGN_HASHTABLE_SIZE];
void hashtable_init(void)
{
// for (int idx = 0; idx < CALLSIGN_HASHTABLE_SIZE; ++idx)
// {
// callsign_hashtable[idx]->callsign[0] = '\0';
// }
memset(callsign_hashtable, 0, sizeof(callsign_hashtable));
}
void hashtable_add(const char* callsign, uint32_t hash)
{
int idx_hash = (hash * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if ((callsign_hashtable[idx_hash].hash == hash) && (0 == strcmp(callsign_hashtable[idx_hash].callsign, callsign)))
{
LOG(LOG_DEBUG, "Found a duplicate [%s]\n", callsign);
return;
}
else
{
LOG(LOG_DEBUG, "Hash table clash!\n");
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
}
strncpy(callsign_hashtable[idx_hash].callsign, callsign, 11);
callsign_hashtable[idx_hash].callsign[11] = '\0';
callsign_hashtable[idx_hash].hash = hash;
}
bool hashtable_lookup(ftx_callsign_hash_type_t hash_type, uint32_t hash, char* callsign)
{
uint32_t hash_mask = (hash_type == FTX_CALLSIGN_HASH_10_BITS) ? 0x3FFu : (hash_type == FTX_CALLSIGN_HASH_12_BITS ? 0xFFFu : 0x3FFFFFu);
int idx_hash = (hash * 23) % CALLSIGN_HASHTABLE_SIZE;
while (callsign_hashtable[idx_hash].callsign[0] != '\0')
{
if ((callsign_hashtable[idx_hash].hash & hash_mask) == hash)
{
strcpy(callsign, callsign_hashtable[idx_hash].callsign);
return true;
}
// Move on to check the next entry in hash table
idx_hash = (idx_hash + 1) % CALLSIGN_HASHTABLE_SIZE;
}
callsign[0] = '\0';
return false;
}
ftx_callsign_hash_interface_t hash_if = {
.lookup_hash = hashtable_lookup,
.save_hash = hashtable_add
};
void test_std_msg(const char* call_to_tx, ftx_field_t to_field, const char* call_de_tx, ftx_field_t de_field, const char* extra_tx, ftx_field_t extra_field)
{
ftx_message_t msg;
ftx_message_init(&msg);
ftx_message_rc_t rc_encode = ftx_message_encode_std(&msg, &hash_if, call_to_tx, call_de_tx, extra_tx);
printf("Encoded [%s] [%s] [%s]\n", call_to_tx, call_de_tx, extra_tx);
CHECK_EQ_VAL(rc_encode, FTX_MESSAGE_RC_OK);
char call_to_arr[14];
char call_de_arr[14];
char extra[14];
char *call_to = call_to_arr;
char *call_de = call_de_arr;
ftx_field_t types[FTX_MAX_MESSAGE_FIELDS];
ftx_message_rc_t rc_decode = ftx_message_decode_std(&msg, &hash_if, call_to, call_de, extra, types);
CHECK_EQ_VAL(rc_decode, FTX_MESSAGE_RC_OK);
printf("Decoded [%s] [%s] [%s]\n", call_to, call_de, extra);
call_to = trim_brackets(call_to);
call_de = trim_brackets(call_de);
CHECK_EQ_VAL(0, strcmp(call_to, call_to_tx));
CHECK_EQ_VAL(0, strcmp(call_de, call_de_tx));
CHECK_EQ_VAL(0, strcmp(extra, extra_tx));
CHECK_EQ_VAL(to_field, types[0]);
CHECK_EQ_VAL(de_field, types[1]);
CHECK_EQ_VAL(extra_field, types[2]);
TEST_END;
}
void test_msg(const char* message_text, ftx_message_type_t expected_type, const char* expected, ftx_callsign_hash_interface_t* hash_if)
{
printf("Testing [%s]\n", message_text);
ftx_message_t msg;
ftx_message_init(&msg);
ftx_message_rc_t rc_encode = ftx_message_encode(&msg, hash_if, message_text);
CHECK_EQ_VAL(rc_encode, FTX_MESSAGE_RC_OK);
CHECK_EQ_VAL(expected_type, ftx_message_get_type(&msg));
char message_decoded[12 + 12 + 20];
ftx_message_offsets_t offsets;
ftx_message_rc_t rc_decode = ftx_message_decode(&msg, hash_if, message_decoded, &offsets);
CHECK_EQ_VAL(rc_decode, FTX_MESSAGE_RC_OK);
printf("Decoded [%s]; offsets %d:%d %d:%d %d:%d\n", message_decoded,
offsets.offsets[0], offsets.types[0], offsets.offsets[1], offsets.types[1], offsets.offsets[2], offsets.types[2]);
CHECK_EQ_VAL(0, strcmp(expected, message_decoded));
// TODO check offsets
TEST_END;
}
#define SIZEOF_ARRAY(x) (sizeof(x) / sizeof((x)[0]))
int main()
{
// test1();
// test4();
const char* callsigns[] = { "YL3JG", "W1A", "W1A/R", "W5AB", "W8ABC", "DE6ABC", "DE6ABC/R", "DE7AB", "DE9A", "3DA0X", "3DA0XYZ", "3DA0XYZ/R", "3XZ0AB", "3XZ0A", "CQ1CQ" };
const char* tokens[] = { "CQ", "QRZ", "CQ 123", "CQ 000", "CQ POTA", "CQ SA", "CQ O", "CQ ASD" };
const ftx_field_t token_types[] = { FTX_FIELD_TOKEN, FTX_FIELD_TOKEN, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG, FTX_FIELD_TOKEN_WITH_ARG };
const char* grids[] = { "KO26", "RR99", "AA00", "RR09", "AA01", "RRR", "RR73", "73", "R+10", "R+05", "R-12", "R-02", "+10", "+05", "-02", "-02", "" };
const ftx_field_t grid_types[] = { FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_GRID, FTX_FIELD_TOKEN, FTX_FIELD_TOKEN, FTX_FIELD_TOKEN, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_RST, FTX_FIELD_NONE };
for (int idx_grid = 0; idx_grid < SIZEOF_ARRAY(grids); ++idx_grid)
{
for (int idx_callsign = 0; idx_callsign < SIZEOF_ARRAY(callsigns); ++idx_callsign)
{
for (int idx_callsign2 = 0; idx_callsign2 < SIZEOF_ARRAY(callsigns); ++idx_callsign2)
{
test_std_msg(callsigns[idx_callsign], FTX_FIELD_CALL, callsigns[idx_callsign2], FTX_FIELD_CALL, grids[idx_grid], grid_types[idx_grid]);
}
}
for (int idx_token = 0; idx_token < SIZEOF_ARRAY(tokens); ++idx_token)
{
for (int idx_callsign2 = 0; idx_callsign2 < SIZEOF_ARRAY(callsigns); ++idx_callsign2)
{
test_std_msg(tokens[idx_token], token_types[idx_token], callsigns[idx_callsign2], FTX_FIELD_CALL, grids[idx_grid], grid_types[idx_grid]);
}
}
}
test_msg("CQ K7IHZ DM43", FTX_MESSAGE_TYPE_STANDARD,
"CQ K7IHZ DM43", &hash_if);
test_msg("CQ EA8/G5LSI", FTX_MESSAGE_TYPE_NONSTD_CALL,
"CQ EA8/G5LSI", &hash_if);
test_msg("EA8/G5LSI R2RFE RR73", FTX_MESSAGE_TYPE_STANDARD,
"<EA8/G5LSI> R2RFE RR73", &hash_if);
test_msg("R2RFE/P EA8/G5LSI R+12", FTX_MESSAGE_TYPE_STANDARD,
"R2RFE/P <EA8/G5LSI> R+12", &hash_if);
test_msg("TNX BOB 73 GL", FTX_MESSAGE_TYPE_FREE_TEXT,
"TNX BOB 73 GL", &hash_if); // message with 4 tokens must be free text
test_msg("TNX BOB 73", FTX_MESSAGE_TYPE_STANDARD,
"<TNX> <BOB> 73", &hash_if); // can't distinguish special callsigns from other tokens
test_msg("CQ YL/LB2JK KO16sw", FTX_MESSAGE_TYPE_NONSTD_CALL,
"CQ YL/LB2JK", &hash_if); // grid not allowed with nonstandard call
test_msg("CQ POTA YL/LB2JK KO16sw", FTX_MESSAGE_TYPE_NONSTD_CALL,
"CQ YL/LB2JK", &hash_if); // CQ modifier not allowed with nonstandard call
test_msg("CQ JA LB2JK JO59", FTX_MESSAGE_TYPE_STANDARD,
"CQ JA LB2JK JO59", &hash_if);
test_msg("CQ 123 LB2JK JO59", FTX_MESSAGE_TYPE_STANDARD,
"CQ 123 LB2JK JO59", &hash_if);
return 0;
}
+1
View File
@@ -0,0 +1 @@
110115 6 0.9 1234 ~ GJ0KYZ RK9AX MO05
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
110130 -6 0.7 683 ~ CQ TA6CQ KN70 AS Turkey
110130 -16 1.0 989 ~ OH3NIV ZS6S -03
110130 -6 0.9 1291 ~ CQ R7IW LN35 EU Russia
110130 -4 0.9 2096 ~ CQ DX R6WA LN32 EU Russia
110130 -14 1.2 2479 ~ TK4LS YC1MRF 73
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
110145 -4 1.0 322 ~ <...> RY8CAA
110145 7 1.0 1234 ~ GJ0KYZ RK9AX MO05
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
110200 -4 0.7 683 ~ CQ TA6CQ KN70 AS Turkey
110200 -16 1.0 990 ~ OH3NIV ZS6S RR73
110200 -17 0.6 1031 ~ CQ LZ1JZ KN22 Bulgaria
110200 -12 0.9 1292 ~ CQ R7IW LN35 EU Russia
110200 -7 0.9 2097 ~ CQ DX R6WA LN32 EU Russia
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
110215 3 1.0 323 ~ <...> RY8CAA R-10
110215 -12 0.1 996 ~ GJ0KYZ UA6HI -15
110215 2 0.9 1235 ~ GJ0KYZ RK9AX MO05
110215 -16 0.9 2059 ~ CQ DX Z33Z KN11 N. Macedonia
Binary file not shown.
+22
View File
@@ -0,0 +1,22 @@
110615 -2 1.0 431 ~ VK4BLE OH8JK R-17
110615 -14 0.9 539 ~ RK6AH JH1AJT -05
110615 -18 0.9 656 ~ PA3EPP SP8NFO KN09
110615 -10 1.8 700 ~ RV6K RU3XL -13
110615 -16 0.9 756 ~ PA3EPP SP8NFO KN09
110615 -11 1.3 810 ~ SQ8OHR UA9LL MO27
110615 15 0.9 906 ~ PA3EPP SP8NFO KN09
110615 10 0.9 1196 ~ ET3RFG/R IN3ADG -23
110615 3 0.9 1284 ~ CQ F4FSY JN25 France
110615 -8 0.9 1349 ~ JR5MJS OH8NW 73
110615 -12 1.0 1404 ~ SV1GN RK6AUV LN05
110615 -24 0.9 1617 ~ PB5DX EI3CTB IO63
110615 10 1.5 2191 ~ CQ IZ1ANK JN33 Italy
110615 0 0.9 2281 ~ NT6Q OH8GDU -17
110615 -7 0.9 2447 ~ CQ DL1UDO JO31 Germany
110615 4 0.8 2576 ~ VK4BLE OH1EDK -20
110615 8 1.0 2656 ~ CQ JA OH1LWZ KP11 Finland
110615 -11 1.0 297 ~ <...> ON7EE JO10
110615 -17 0.8 594 ~ CQ DG0OFT JO50 Germany
110615 -16 0.8 1049 ~ CQ UB3AQS KO85 EU Russia
110615 -3 1.0 1201 ~ G1XJM HA7JIV JN97
110615 -16 1.4 2727 ~ SP7XIF JA2GQT -15
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
110630 -20 1.1 518 ~ CQ PC2J JO22
110630 4 1.2 809 ~ UA9LL SQ8OHR -10
110630 15 -0.5 973 ~ JA2GQT SP7XIF JO91
110630 -3 0.8 1034 ~ CQ EA3UV JN01
110630 -5 1.4 1405 ~ RK6AUV SV1GN -18
110630 -15 1.0 1485 ~ SP8NFO PA3EPP +04
110630 -6 0.9 1670 ~ CQ PB5DX JO22
110630 -9 0.9 1722 ~ CQ SM7HZK JO76
110630 5 0.8 1954 ~ JH1AJT RK6AH R+07
110630 -2 0.9 2030 ~ JL1TZQ R3BV R-18
110630 -16 0.9 2110 ~ <...> DF1XG JO53
110630 19 1.3 2728 ~ CQ DX IK0YVV JN62
110630 -10 0.9 840 ~ CQ OR18RSX
110630 -24 0.3 1114 ~ CQ JR5MJS PM74
110630 -21 1.0 1695 ~ JA2GQT F8NHF -10
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
110645 0 0.9 430 ~ VK4BLE OH8JK R-17
110645 -18 1.8 699 ~ CQ RU3XL KO84
110645 -23 0.7 756 ~ PA3EPP SP8NFO R+01
110645 9 0.7 906 ~ PA3EPP SP8NFO R+01
110645 -20 0.9 1049 ~ CQ UB3AQS KO85
110645 10 0.9 1196 ~ ET3RFG/R IN3ADG -23
110645 -1 0.9 1283 ~ CQ F4FSY JN25
110645 -16 1.0 1404 ~ SV1GN RK6AUV R-03
110645 -24 0.9 1617 ~ PB5DX EI3CTB IO63
110645 -10 1.0 2111 ~ CQ OR18TRA
110645 3 1.5 2191 ~ PC2J IZ1ANK +01
110645 -4 0.9 2281 ~ CQ OH8GDU KP24
110645 -10 0.9 2447 ~ CQ DL1UDO JO31
110645 6 0.7 2576 ~ VK4BLE OH1EDK -20
110645 5 1.0 2656 ~ CQ JA OH1LWZ KP11
110645 -21 0.8 594 ~ CQ DG0OFT JO50
110645 -21 0.7 1114 ~ <...> DA0FONTANE
110645 -6 1.0 1201 ~ G1XJM HA7JIV JN97
110645 -21 0.9 2092 ~ WB2QJ ES3AT KO18
110645 -15 1.4 2726 ~ SP7XIF JA2GQT -13

Some files were not shown because too many files have changed in this diff Show More