[refactor](trx-rs): resolve all improvement areas (P1–P3)

P1 — High:
- Merge duplicate APRS/HF-APRS decoder tasks into parameterised inner fn
- Merge duplicate FT8/FT4 decoder tasks into shared ftx inner fn
- Add multi-rig state isolation and command routing tests (listener.rs)
- Add background decode evaluate_bookmark unit tests

P2 — Medium:
- Fix decode-log silent flush errors and rotation failure fallback
- Split api.rs (2,831 LOC) into 7 logical modules (decoder, rig, vchan,
  sse, bookmarks, assets, mod)
- Extract background decode decision cascade into pure evaluate_bookmark()
  function with ChannelAction enum
- Relax actix-web pin from =4.4.1 to 4.4
- Replace VDES magic numbers with named constants

P3 — Low:
- Add doc comments to AisDecoder, VdesDecoder, RdsDecoder
- Add debug_assert on turbo decoder interleaver/deinterleaver lengths
- Add tracing info_span! to all 10 decoder block_in_place calls
- Optimize hot-path string cloning in remote_client spectrum loop

https://claude.ai/code/session_01Y3G65hrfsRRjwyBF2qbBmc
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-29 14:40:03 +00:00
committed by Stan Grams
parent 44e09449dc
commit c041ac83f3
21 changed files with 4566 additions and 3359 deletions
+16
View File
@@ -47,6 +47,22 @@ struct RawFrame {
crc_ok: bool,
}
/// AIS (Automatic Identification System) GMSK/HDLC decoder.
///
/// Operates on narrowband FM-demodulated audio at any sample rate (internally
/// resampled to the 9,600 baud AIS symbol rate). The decoder performs sign
/// slicing, NRZI decoding, HDLC flag detection with bit de-stuffing, CRC-16
/// validation, and parsing of common AIS message types (13, 5, 18, 19).
///
/// # Usage
///
/// ```ignore
/// let mut decoder = AisDecoder::new(48_000);
/// let messages = decoder.process_samples(&pcm_samples, "A");
/// ```
///
/// Call [`reset()`](Self::reset) when switching frequency or restarting
/// reception to clear internal symbol-tracking state.
#[derive(Debug, Clone)]
pub struct AisDecoder {
sample_rate: f32,
+8 -3
View File
@@ -143,8 +143,11 @@ impl DecoderFileLogger {
state.writer = next_writer;
}
Err(e) => {
warn!("decode log reopen failed for {}: {}", self.label, e);
return;
warn!(
"decode log rotation failed for {}, keeping current writer: {}",
self.label, e
);
// Keep the old writer rather than silently dropping writes.
}
}
}
@@ -157,7 +160,9 @@ impl DecoderFileLogger {
warn!("decode log write failed for {}", self.label);
return;
}
let _ = state.writer.flush();
if let Err(e) = state.writer.flush() {
warn!("decode log flush failed for {}: {}", self.label, e);
}
}
}
+23
View File
@@ -787,6 +787,29 @@ fn af_code_to_hz(code: u8) -> Option<u32> {
// RdsDecoder — main public entry point
// ---------------------------------------------------------------------------
/// RDS (Radio Data System) decoder for WFM broadcast signals.
///
/// Operates on baseband WFM audio at the configured sample rate. The decoder
/// performs 57 kHz subcarrier recovery (via Costas loop or pilot-derived
/// reference), RRC matched filtering, biphase (Manchester) clock recovery
/// with multi-candidate tracking, CRC-10 syndrome checking with OSD(2)
/// error correction, and full Group A/B parsing (PI, PS, RT, AF, CT, PTY).
///
/// # Usage
///
/// ```ignore
/// let mut decoder = RdsDecoder::new(228_000);
/// // Optionally lock to pilot-derived 57 kHz reference:
/// // decoder.set_pilot_ref(cos57, sin57);
/// for &sample in &baseband_samples {
/// if let Some(rds) = decoder.process_sample(sample, 1.0) {
/// println!("PI={:04X} PS={}", rds.pi_code, rds.ps_name);
/// }
/// }
/// ```
///
/// Call [`clear_pilot_ref()`](Self::clear_pilot_ref) to revert to free-running
/// Costas loop carrier recovery when the pilot tone is lost.
#[derive(Debug, Clone)]
pub struct RdsDecoder {
sample_rate_hz: u32,
+30 -3
View File
@@ -44,6 +44,17 @@ const BURST_TRIGGER_FLOOR: f32 = 1.0e-10;
const BURST_SUSTAIN_NOISE_MULT: f32 = 1.15;
const BURST_SUSTAIN_FLOOR: f32 = 1.0e-11;
/// Plausibility score below which a burst is treated as unsynced noise.
/// The scoring scale is an integer sum of weighted heuristics (link-ID
/// validity, tail-zero count, payload structure). 35 rejects bursts
/// that fail nearly every plausibility check.
const PLAUSIBILITY_UNSYNCED_THRESHOLD: i32 = -35;
/// Plausibility score below which the FEC label is annotated with
/// "Low confidence". 15 corresponds to marginal decodes where enough
/// heuristics pass to attempt CRC but the result is uncertain.
const PLAUSIBILITY_LOW_CONFIDENCE_THRESHOLD: i32 = 15;
/// Warmup period: number of samples to observe before burst detection starts.
/// This allows the noise-floor EMA (α = 0.005, τ ≈ 200 samples) to converge
/// to the actual SDR noise level. Without warmup the initial floor of 1e-12
@@ -52,6 +63,22 @@ const BURST_SUSTAIN_FLOOR: f32 = 1.0e-11;
/// reaches quiet_limit and the burst never terminates.
const NOISE_FLOOR_WARMUP_SECS: f32 = 0.05; // 50 ms ≈ 10 EMA time-constants
/// VDES (VHF Data Exchange System) TER-MCS-1 decoder for 100 kHz channels.
///
/// Consumes complex baseband IQ samples and performs burst detection,
/// π/4-QPSK demodulation, block deinterleaving, Turbo FEC decoding
/// (dual 8-state RSC with BCJR/MAP), CRC-16 validation, and ITU-R
/// M.2092-1 link-layer frame parsing.
///
/// # Usage
///
/// ```ignore
/// let mut decoder = VdesDecoder::new(192_000);
/// let messages = decoder.process_samples(&iq_samples);
/// ```
///
/// Call [`reset()`](Self::reset) when switching frequency to clear burst
/// detection state and noise-floor estimates.
#[derive(Debug, Clone)]
pub struct VdesDecoder {
sample_rate: f32,
@@ -259,7 +286,7 @@ impl VdesDecoder {
.filter(|bit| *bit == 0)
.count();
let plausibility = vdes_plausibility_score(&parsed, link_id, tail_zero_bits);
if plausibility < -35 {
if plausibility < PLAUSIBILITY_UNSYNCED_THRESHOLD {
return Some(build_unsynced_message(
channel,
&framed,
@@ -276,7 +303,7 @@ impl VdesDecoder {
format!(
"Turbo FEC (8-iter BCJR), reliability {:.2}{}",
turbo_reliability,
if plausibility < 15 {
if plausibility < PLAUSIBILITY_LOW_CONFIDENCE_THRESHOLD {
" · Low confidence"
} else {
""
@@ -287,7 +314,7 @@ impl VdesDecoder {
"Hard-decision 1/2 Viterbi, tail {} / {} zero bits{}",
tail_zero_bits,
TER_MCS1_100_FEC_TAIL_BITS,
if plausibility < 15 {
if plausibility < PLAUSIBILITY_LOW_CONFIDENCE_THRESHOLD {
" · Low confidence"
} else {
""
+10
View File
@@ -206,7 +206,17 @@ pub fn turbo_decode_soft(received_llrs: &[Llr], info_len: usize) -> (Vec<u8>, f3
}
let interleaver = qpp_interleaver(info_len);
debug_assert_eq!(
interleaver.len(),
info_len,
"interleaver length must equal info_len"
);
let deinterleaver = invert_permutation(&interleaver);
debug_assert_eq!(
deinterleaver.len(),
info_len,
"deinterleaver length must equal info_len"
);
let (sys_llr, par1_llr, par2_llr) = depuncture_rate_half(received_llrs, info_len);