The previous carrier power IIR filtered |IQ|² (power), which only smoothed
temporal fluctuations but still integrated noise across the full 180 kHz WFM
channel bandwidth. This caused background noise to read ~-78 dBFS instead of
the expected ~-110 dBFS (~32 dB too high ≈ 10·log₁₀(180kHz/500Hz)).
Move the single-pole IIR lowpass to the IQ domain (filter I and Q separately
at ~500 Hz cutoff), then compute power from the filtered output. This rejects
out-of-band noise before the power measurement, so the meter reads true
carrier level rather than total wideband noise.
https://claude.ai/code/session_01W4WPMB2Lg3hgaY6opsk25f
Signed-off-by: Claude <noreply@anthropic.com>
Replace peak |IQ|² measurement with a per-sample single-pole IIR lowpass
on the instantaneous power (~500 Hz cutoff). FM has constant envelope so
the IIR converges to the true carrier power A², rejecting wideband noise
that previously inflated the peak reading and masked actual signal level.
Other modes keep the existing peak + EMA approach.
https://claude.ai/code/session_01X6tedMVpjX3DEqLFDBR7FK
Signed-off-by: Claude <noreply@anthropic.com>
The 8th-order (4×biquad) RDS bandpass at Q=5 per stage produced a
composite −3 dB bandwidth of ±2480 Hz, but the steep 8th-order roll-off
tapered the RDS signal edges (±1544 Hz at α=0.30) by −1.2 dB. This
distorted the RRC matched filter's expected flat spectrum, causing ISI
and degrading soft-decision confidence — directly hurting PS/RT decode
on weak signals.
Q=3.5 widens the composite passband to ±3560 Hz, reducing band-edge
attenuation to −0.59 dB while still providing ≈−4 dB rejection at the
stereo difference signal edge (53 kHz) and steep 8th-order far-out
roll-off.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
https://claude.ai/code/session_01Sw9esAuic8KHP1t8nZgvH2
Signed-off-by: Claude <noreply@anthropic.com>
Replace the DC-component approach (which underreads FM due to carrier deviation) with peak |s|² on the filtered+decimated IQ before AGC is applied. Works correctly for both constant-envelope FM and narrowband modes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
EMA (α=0.4) smooths the carrier power estimate across DSP blocks. Custom PartialEq on VchanRdsEntry excludes signal_db so rapidly-changing levels do not trigger main state SSE updates that overwrite the frequency input.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Use the DC component of the baseband-mixed IQ (before LPF/decimation) as a narrow-band carrier power estimate. This correlates with the spectrum FFT peak instead of measuring wideband channel power which inflates the reading for WFM.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
ACI: the hard limiter in channel.rs normalised IQ samples to unit
magnitude *before* the CMA equalizer, making the signal perfectly
constant-modulus so the CMA never adapted and tap deviation stayed
at zero. Fix by moving the hard limiter inside process_iq (after
the CMA) and replacing the CMA-based metric with IQ envelope
coefficient of variation, computed on the raw samples.
CCI: the pilot coherence has a theoretical maximum of π/4 ≈ 0.785
(not 1.0), so coherence_penalty was always ~0.215 even for a clean
signal. The Q/I ratio also depended on the arbitrary NCO-pilot
phase offset rather than actual interference. Fix by normalising
coherence by its theoretical max and dropping the phase-dependent
Q/I ratio. Gate CCI on pilot detection so mono signals read 0%.
https://claude.ai/code/session_01PUXWNMRGfrWYH56k2DLmen
Signed-off-by: Claude <noreply@anthropic.com>
Estimate Co-Channel Interference (CCI) from pilot tone quadrature
leakage and coherence degradation. Estimate Adjacent Channel
Interference (ACI) from CMA equalizer tap deviation from identity.
Both metrics (0-100 scale) are surfaced through RigFilterState and
displayed as colour-coded bars in the WFM control panel.
The RDS decoder quality parameter is now adaptively penalised when
CCI/ACI levels are elevated, reducing block-error rate under
interference conditions.
https://claude.ai/code/session_016EKzep42RCvE4GxvvRaCwu
Signed-off-by: Claude <noreply@anthropic.com>
- RRC_ALPHA 0.75→0.50: narrower noise BW, ~0.6 dB SNR gain
- COSTAS_KI 3.5e-7: maintain ζ≈0.68 (1e-6 caused loop instability)
- Soft confidence: use biphase_i.abs() instead of full vector magnitude
so OSD confidence is aligned with bit-decision sign; suppresses
false groups under noise with residual Costas phase error
- OSD(2) in locked mode: corrects ≤2-bit errors after block sync
- Search mode: hard decode only for Block A; OSD(1) in search yielded
~13% false Block A rate per bit, letting wrong clock candidates
accumulate false groups as fast as the correct candidate
- Incumbent candidate tracking (best_candidate_idx): the winning
candidate updates best_state at equal score; challengers need strictly
higher score; best_score tracks incumbent even on no-state-change
groups so challengers can't leapfrog on a single false group
- blocks_to_chips: add NRZI (NRZ-Mark) pre-encoding so the differential
biphase decoder recovers actual data bits rather than XOR-of-pairs
- Add blocks_to_chips_round_trips_all_groups test: verifies all 16 blocks
across 4 PS segments round-trip correctly without BPSK modulation
[fix](trx-backend-soapysdr): lower pilot lock threshold for weak-signal RDS
- PILOT_LOCK_THRESHOLD 0.25→0.20, add PILOT_LOCK_ONSET=0.30 constant
- Pilot reference engages at coherence ≥0.36 (was ≥0.45)
WIP: end_to_end_clean_signal_decodes_ps still failing (13/15 pass).
Decoder skips segment 2 due to ISI from rectangular test chips through
RRC receive filter. chips_to_rds_signal needs RRC pulse shaping.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Lower PILOT_LOCK_THRESHOLD 0.5 -> 0.25 so the accurate 57 kHz pilot-derived
carrier reference is handed to the RDS decoder even with a weaker pilot tone.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The IQ prefilter cutoff was audio_bandwidth_hz/2, so any setting below
~120 kHz would cut off the 57 kHz RDS subcarrier before FM demod.
- Clamp IQ prefilter cutoff to >= 60 kHz for WFM in both new() and
rebuild_filters() — audio quality is unaffected since WfmStereoDecoder
applies its own 18 kHz lowpass internally
- Ensure pipeline target rate >= 120 kHz for WFM so the decimated IQ
sample rate can represent the 60 kHz cutoff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Tech 1: replace one-pole baseband LPF with FIR RRC matched filter
(alpha=0.75, 4-chip span) — largest single measured improvement per
empirical comparison (gr-rds RRC vs plain FIR: 32/38 vs 18/38 stations).
Tech 2: 19 kHz pilot x3 -> 57 kHz coherent carrier reference via the
triple-angle formula; fed from the WFM pilot Costas PLL when
pilot_lock_level > 0.5, clearing to NCO fallback otherwise.
Tech 3/7/8: OSD(2) soft-decision block decoder replaces hard CRC check.
Per-bit soft magnitudes accumulated in Candidate::block_soft[26].
decode_block_soft() searches Hamming distance 0/1/2 (352 trials total)
and returns the minimum Euclidean-cost valid codeword; ~2-3 dB gain.
Tech 4: 8th-order 57 kHz BPF (4 cascaded biquads at Q=5) in wfm.rs
replaces the previous single Q=10 biquad; ~6x steeper ACI stopband.
Tech 5: Costas loop with tanh soft phase detector drives the RDS carrier
NCO when no pilot reference is available (P+I, B_L ~20 Hz).
Tech 6: Block A PI field LLR accumulation — signed per-bit LLR summed
over 3 independent Block A observations before committing the PI value,
correcting weak-signal false locks without delaying strong-signal lock.
Tech 9: 8-tap complex CMA blind equalizer applied to IQ samples before
FM discrimination; constant-modulus error (|y|^2 - R^2) drives tap
adaptation without a training sequence, suppressing adjacent-channel
interference at the source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Use saturating CAS loop in adjust_total_count to prevent AtomicUsize underflow, and cap history estimate at 500k entries.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
The map module was tagging all decode markers (APRS, AIS, VDES,
FT8/FT4/FT2/WSPR locators) with the global rig picker's active rig
instead of the actual source rig. This made the map's own rig filter
dropdown ineffective in multi-rig setups.
- Add rig_id field to all decode message structs (AisMessage,
VdesMessage, AprsPacket, CwEvent, Ft8Message, WsprMessage)
- Set rig_id on messages in audio_client before broadcasting, using
the actual rig connection identifier
- Update history collector to prefer message rig_id over the global
active rig fallback
- Pass rig_id through plugin normalize functions (AIS, APRS, VDES,
HF-APRS) so it reaches the map add functions
- Update all map marker functions (aprsMapAddStation, aisMapAddVessel,
vdesMapAddPoint, mapAddLocator) to use the message's rig_id with
fallback to the global picker for backward compatibility
https://claude.ai/code/session_015gC7axHk2jmp7HbFPdbivN
Signed-off-by: Claude <noreply@anthropic.com>
Drop the plugin loading infrastructure (libloading-based dynamic .so/.dylib/.dll
loading) from both trx-server and trx-client. The feature was unused and posed an
unnecessary security risk by executing arbitrary native code from disk.
Removed:
- src/trx-app/src/plugins.rs (plugin discovery, validation, FFI registration)
- examples/trx-plugin-example/ (cdylib example plugin)
- libloading dependency from trx-app
- load_backend_plugins / load_frontend_plugins calls from server and client
- Plugin documentation from README.md and CLAUDE.md
https://claude.ai/code/session_01DTEUpz3XPUeWmz74NeaFgb
Signed-off-by: Claude <noreply@anthropic.com>
Update all SDR command handlers in rig_task to access SDR methods via
ctx.rig.as_sdr() instead of calling them directly on RigCat. Query-only
SDR operations (filter_state, get_spectrum, get_vchan_rds) use
as_sdr_ref(). Non-SDR rigs now get proper not_supported errors.
https://claude.ai/code/session_01XzurkeuUmamBuhQwxVy7T4
Signed-off-by: Claude <noreply@anthropic.com>
Add an AtomicUsize total_count field to DecoderHistories, maintained by
record/prune/clear methods, so estimated_total_count() avoids 9 separate
mutex acquisitions. Also replace audio ring buffer .unwrap() calls with
.unwrap_or_else(|e| e.into_inner()) to recover from poisoned locks.
https://claude.ai/code/session_01XzurkeuUmamBuhQwxVy7T4
Signed-off-by: Claude <noreply@anthropic.com>
IQ-domain impulse noise blanker using exponential-smoothing RMS tracker. Samples exceeding threshold × running RMS are replaced with the last clean sample. Configurable via [sdr.noise_blanker] in TOML config and runtime via POST /set_sdr_noise_blanker API.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
When Device::new(args) fails, enumerate all available SoapySDR devices and
include them in the error message. Also hint that args are case-sensitive.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
When specific SoapySDR device args are provided (e.g. with a serial number),
fail hard instead of silently falling back to Device::new("") which opens
the first available device. This caused multi-device setups to bind both
rig instances to the same physical device.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Normalize tracked SPDX headers to the 2026 Stan Grams identity.
Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
Delete trx-ft8 (C wrapper around ft8_lib + ft2_ldpc) and update
trx-server to depend on trx-ftx (pure Rust) directly. Removes
~2,900 lines of C code and all unsafe FFI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>