diff --git a/src/decoders/trx-wefax/src/config.rs b/src/decoders/trx-wefax/src/config.rs index 7189b02..b9755f0 100644 --- a/src/decoders/trx-wefax/src/config.rs +++ b/src/decoders/trx-wefax/src/config.rs @@ -19,9 +19,6 @@ pub struct WefaxConfig { pub output_dir: Option, /// Whether to emit line-by-line progress events. pub emit_progress: bool, - /// Whether to continuously track and correct sample-clock drift - /// (line-to-line cross-correlation) to remove image slant. - pub slant_correction: bool, } impl Default for WefaxConfig { @@ -33,7 +30,6 @@ impl Default for WefaxConfig { deviation_hz: 400.0, output_dir: None, emit_progress: true, - slant_correction: true, } } } diff --git a/src/decoders/trx-wefax/src/decoder.rs b/src/decoders/trx-wefax/src/decoder.rs index 60090a5..3516162 100644 --- a/src/decoders/trx-wefax/src/decoder.rs +++ b/src/decoders/trx-wefax/src/decoder.rs @@ -43,26 +43,11 @@ const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2; /// fldigi's line-to-line correlation check for automatic stop. const LINE_CORR_NOISE_LINES: u32 = 30; -/// Pearson correlation above which adjacent lines are considered good -/// evidence of real image content. Used to verify unverified auto-starts. -const LINE_CORR_IMAGE_THRESHOLD: f32 = 0.5; - -/// Number of consecutive well-correlated lines that verify an unverified -/// reception (i.e. an auto-start from variance detection). Low enough to -/// engage quickly on real imagery. -const VERIFY_HIGH_CORR_STREAK: u32 = 5; - -/// Maximum number of scan lines the verifier waits for before giving up on -/// an unverified reception. Roughly 20 s at 120 LPM. If no high-correlation -/// streak appears by then, the buffered content is dropped and we return -/// to Idle without saving anything. -const VERIFY_TIMEOUT_LINES: u32 = 40; - /// Maximum number of scan-line-equivalent sample windows to wait for phasing -/// lock before falling through to Receiving (unverified). Typical WEFAX -/// phasing lasts ~30 s; if the phasing detector hasn't converged by then -/// we give up on alignment and let the correlation verifier decide whether -/// the content that follows is a real image. At 120 LPM this is ~30 s. +/// lock before falling through to Receiving. Typical WEFAX phasing lasts +/// ~30 s; if the phasing detector hasn't converged by then we give up on +/// alignment and let the carrier-loss watchdog decide whether the content +/// that follows is real imagery. At 120 LPM this is ~30 s. const PHASING_TIMEOUT_LINES: u32 = 60; /// WEFAX decoder output event. @@ -117,18 +102,10 @@ pub struct WefaxDecoder { /// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress /// image (carrier dropped / tx ended without an APT stop tone). low_corr_lines: u32, - /// `true` once the current reception has been confirmed to contain real - /// image content. Set immediately for phasing-driven entries (the APT - /// start tone + phasing pulses already proved the signal); set later - /// by the correlation verifier for variance-driven auto-starts. - verified: bool, - /// Rolling count of consecutive well-correlated lines, used to confirm - /// an unverified reception. - high_corr_streak: u32, /// Number of luminance samples processed while in `State::Phasing`. /// When this exceeds the equivalent of `PHASING_TIMEOUT_LINES` lines, - /// the decoder falls through to Receiving (unverified) so a noisy or - /// partial phasing signal doesn't wedge the state machine. + /// the decoder falls through to Receiving so a noisy or partial + /// phasing signal doesn't wedge the state machine. phasing_samples: u64, /// Current rig dial frequency in Hz (for image filenames). freq_hz: u64, @@ -157,8 +134,6 @@ impl WefaxDecoder { signal_detect_count: 0, signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2), low_corr_lines: 0, - verified: false, - high_corr_streak: 0, phasing_samples: 0, freq_hz: 0, mode: String::new(), @@ -267,7 +242,7 @@ impl WefaxDecoder { .as_millis() as i64, ); self.signal_detect_buf.clear(); - events.push(self.transition_to_receiving(ioc, lpm, 0, false)); + events.push(self.transition_to_receiving(ioc, lpm, 0)); break; } @@ -297,12 +272,12 @@ impl WefaxDecoder { if let Some(ref mut phasing) = self.phasing { if let Some(offset) = phasing.process(&luminance) { - events.push(self.transition_to_receiving(ioc, lpm, offset, true)); + events.push(self.transition_to_receiving(ioc, lpm, offset)); } else { // Phasing timeout: if alignment doesn't converge in // ~PHASING_TIMEOUT_LINES lines, fall through to - // Receiving (unverified) and let the correlation - // verifier decide. + // Receiving and let the carrier-loss watchdog decide + // whether the content that follows is real imagery. self.phasing_samples += luminance.len() as u64; let spl = WefaxConfig::samples_per_line(lpm, INTERNAL_RATE) as u64; if self.phasing_samples >= spl * PHASING_TIMEOUT_LINES as u64 { @@ -310,7 +285,7 @@ impl WefaxDecoder { ioc, lpm, "WEFAX: phasing timeout — falling through to receiving" ); - events.push(self.transition_to_receiving(ioc, lpm, 0, false)); + events.push(self.transition_to_receiving(ioc, lpm, 0)); } } } @@ -327,66 +302,36 @@ impl WefaxDecoder { // Feed luminance to line slicer. let mut carrier_lost = false; - let mut verify_failed = false; if let Some(ref mut slicer) = self.slicer { let new_lines = slicer.process(&luminance); for line in new_lines { if let Some(ref mut image) = self.image { - // Line-to-line Pearson correlation classifies the - // new line as image-like, noise-like, or flat. - // fldigi-style: real imagery has highly correlated - // adjacent lines; pure noise does not. + // Carrier-loss watchdog: real imagery has highly + // correlated adjacent lines; pure noise does not. + // After LINE_CORR_NOISE_LINES consecutive low- + // correlation lines we finalize (fldigi-style + // automatic stop). if let Some(r) = image.correlation_with_last(&line) { - if r >= LINE_CORR_IMAGE_THRESHOLD { - self.high_corr_streak += 1; - self.low_corr_lines = 0; - if !self.verified - && self.high_corr_streak >= VERIFY_HIGH_CORR_STREAK - { - self.verified = true; - debug!( - lines = image.line_count(), - "WEFAX: reception verified from line correlation" - ); - } - } else if r < LINE_CORR_NOISE_THRESHOLD { + if r < LINE_CORR_NOISE_THRESHOLD { self.low_corr_lines += 1; - self.high_corr_streak = 0; trace!( r = format!("{:.3}", r), count = self.low_corr_lines, "WEFAX low line-correlation" ); } else { - // Middle zone — reset high streak, hold - // low-corr counter. - self.high_corr_streak = 0; + self.low_corr_lines = 0; } } // Flat lines (correlation == None) don't advance - // either counter — solid bands in real imagery - // shouldn't be scored as noise OR as evidence. + // the counter but also don't reset it — an image + // with a solid band surrounded by noise still + // trips the watchdog once the noise resumes. image.push_line(line); let count = image.line_count(); - // Unverified timeout: if we got here from a - // variance auto-start and line correlation never - // took hold, the "signal" wasn't real WEFAX. - // Abandon without saving. - if !self.verified && count >= VERIFY_TIMEOUT_LINES { - debug!( - lines = count, - "WEFAX: failed to verify image content — abandoning" - ); - verify_failed = true; - break; - } - - // Carrier-loss watchdog — only active once the - // reception has been verified (otherwise it - // double-counts with the verify timeout). - if self.verified && self.low_corr_lines >= LINE_CORR_NOISE_LINES { + if self.low_corr_lines >= LINE_CORR_NOISE_LINES { debug!( lines = count, "WEFAX: line correlation lost — auto-finalizing image" @@ -418,15 +363,6 @@ impl WefaxDecoder { } } - if verify_failed { - // Drop buffered content without saving — this was a - // false auto-start (tone, noise burst, etc.). - self.image = None; - self.reception_start_ms = None; - self.transition_to_idle(); - return events; - } - if carrier_lost { events.extend(self.finalize_image(ioc, lpm)); self.transition_to_idle(); @@ -465,8 +401,6 @@ impl WefaxDecoder { self.signal_detect_count = 0; self.signal_detect_buf.clear(); self.low_corr_lines = 0; - self.verified = false; - self.high_corr_streak = 0; self.phasing_samples = 0; events } @@ -522,30 +456,13 @@ impl WefaxDecoder { self.state_event("Phasing", ioc, lpm) } - fn transition_to_receiving( - &mut self, - ioc: u16, - lpm: u16, - phase_offset: usize, - verified: bool, - ) -> WefaxEvent { - debug!( - ioc, - lpm, phase_offset, verified, "WEFAX: entering receiving" - ); + fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) -> WefaxEvent { + debug!(ioc, lpm, phase_offset, "WEFAX: entering receiving"); let ppl = WefaxConfig::pixels_per_line(ioc) as usize; - self.slicer = Some(LineSlicer::with_slant( - lpm, - ioc, - INTERNAL_RATE, - phase_offset, - self.config.slant_correction, - )); + self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset)); self.image = Some(ImageAssembler::new(ppl)); self.tone_detector.reset(); self.low_corr_lines = 0; - self.verified = verified; - self.high_corr_streak = 0; self.state = State::Receiving { ioc, lpm }; self.state_event("Receiving", ioc, lpm) } @@ -559,8 +476,6 @@ impl WefaxDecoder { self.signal_detect_count = 0; self.signal_detect_buf.clear(); self.low_corr_lines = 0; - self.verified = false; - self.high_corr_streak = 0; self.phasing_samples = 0; } diff --git a/src/decoders/trx-wefax/src/line_slicer.rs b/src/decoders/trx-wefax/src/line_slicer.rs index 480e7c4..9b06ef3 100644 --- a/src/decoders/trx-wefax/src/line_slicer.rs +++ b/src/decoders/trx-wefax/src/line_slicer.rs @@ -7,21 +7,9 @@ //! Once the phasing detector has established a line-start phase offset, //! the line slicer accumulates demodulated luminance samples and extracts //! complete image lines at the configured LPM rate. -//! -//! When `slant_correction` is enabled, the slicer tracks line-to-line -//! drift via cross-correlation with the previous line and nudges the -//! extraction cursor by ±`MAX_DRIFT_SAMPLES` per line. This compensates -//! for the small mismatch between the transmitter's and receiver's -//! sample clocks that would otherwise skew the assembled image. use crate::config::WefaxConfig; -/// Maximum per-line drift (in samples at the internal rate) searched for -/// when slant correction is enabled. At 120 LPM / 11025 Hz there are -/// ~5512 samples per line, so ±6 samples is ~0.1% drift per line — more -/// than enough for any real-world sample-clock mismatch. -const MAX_DRIFT_SAMPLES: usize = 6; - /// Line slicer for WEFAX image assembly. pub struct LineSlicer { /// Samples per line at the internal sample rate. @@ -30,34 +18,14 @@ pub struct LineSlicer { pixels_per_line: usize, /// Phase offset in samples from the phasing detector. phase_offset: usize, - /// Accumulated luminance samples. While `slant_correction` is on, - /// the buffer anchor is the *start of the previous line* (so the - /// first `samples_per_line` samples are the reference for drift - /// tracking). Without slant correction the anchor is simply the - /// start of the next line to extract. + /// Accumulated luminance samples. buffer: Vec, /// Whether we have aligned to the phase offset yet. aligned: bool, - /// Whether a reference (previous) line is held at the buffer anchor. - has_reference: bool, - /// Enable line-to-line drift tracking. - slant_correction: bool, - /// Cumulative drift applied so far (samples). Diagnostic. - pub(crate) total_drift: i64, } impl LineSlicer { pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self { - Self::with_slant(lpm, ioc, sample_rate, phase_offset, true) - } - - pub fn with_slant( - lpm: u16, - ioc: u16, - sample_rate: u32, - phase_offset: usize, - slant_correction: bool, - ) -> Self { let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate); let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize; @@ -65,11 +33,8 @@ impl LineSlicer { samples_per_line, pixels_per_line, phase_offset, - buffer: Vec::with_capacity(samples_per_line * 3), + buffer: Vec::with_capacity(samples_per_line * 2), aligned: false, - has_reference: false, - slant_correction, - total_drift: 0, } } @@ -90,56 +55,16 @@ impl LineSlicer { self.aligned = true; } - let spl = self.samples_per_line; - - if !self.slant_correction { - // Simple fixed-period extraction. - let mut offset = 0; - while offset + spl <= self.buffer.len() { - let line_samples = &self.buffer[offset..offset + spl]; - let pixels = self.resample_line(line_samples); - lines.push(pixels); - offset += spl; - } - if offset > 0 { - self.buffer.drain(..offset); - } - return lines; - } - - // Slant-corrected extraction. - let max_shift = MAX_DRIFT_SAMPLES; - - // Bootstrap: the very first line has no previous reference. - // Extract it naively and keep it in the buffer as the reference. - if !self.has_reference { - if self.buffer.len() < spl { - return lines; - } - let first = self.buffer[0..spl].to_vec(); - let pixels = self.resample_line(&first); + // Extract complete lines (single drain at the end to avoid O(n²)). + let mut offset = 0; + while offset + self.samples_per_line <= self.buffer.len() { + let line_samples = &self.buffer[offset..offset + self.samples_per_line]; + let pixels = self.resample_line(line_samples); lines.push(pixels); - self.has_reference = true; - // Do NOT drain: the first `spl` samples remain as the - // reference for the next line's drift search. + offset += self.samples_per_line; } - - // Subsequent lines: for each iteration, buffer[0..spl] is the - // reference line, and we search for the best starting position - // of the NEXT line in the range [spl - max_shift, spl + max_shift]. - while self.buffer.len() >= 2 * spl + max_shift { - let prev = &self.buffer[0..spl]; - let (best_d, _best_r) = search_best_shift(prev, &self.buffer, spl, max_shift); - - let start = (spl as i32 + best_d) as usize; - let next_line = self.buffer[start..start + spl].to_vec(); - let pixels = self.resample_line(&next_line); - lines.push(pixels); - - // Advance the anchor to the start of the line we just - // emitted — it becomes the reference for the next iteration. - self.buffer.drain(..start); - self.total_drift += best_d as i64; + if offset > 0 { + self.buffer.drain(..offset); } lines @@ -149,16 +74,9 @@ impl LineSlicer { self.pixels_per_line } - /// Samples per line at the internal rate (for diagnostics). - pub fn samples_per_line(&self) -> usize { - self.samples_per_line - } - pub fn reset(&mut self) { self.buffer.clear(); self.aligned = false; - self.has_reference = false; - self.total_drift = 0; } /// Resample a line's worth of luminance samples to the target pixel count @@ -189,82 +107,6 @@ impl LineSlicer { } } -/// Search for the drift `d ∈ [-max_shift, +max_shift]` that maximises -/// the Pearson correlation between `reference` and -/// `buffer[spl+d .. spl+d+spl]`. -/// -/// Returns `(best_d, best_r)`. A correlation-peak deadband prefers -/// `d = 0` when the peak is only marginally better than at zero, which -/// keeps tracking stable on quiet lines. -fn search_best_shift( - reference: &[f32], - buffer: &[f32], - spl: usize, - max_shift: usize, -) -> (i32, f32) { - debug_assert!(buffer.len() >= 2 * spl + max_shift); - debug_assert_eq!(reference.len(), spl); - - // Pre-compute reference mean + variance. - let n = spl as f32; - let mean_r = reference.iter().sum::() / n; - let mut var_r = 0.0f32; - for &v in reference { - let d = v - mean_r; - var_r += d * d; - } - - // Guard against a flat reference line — drift tracking is useless. - const MIN_VAR: f32 = 32.0; - if var_r < MIN_VAR { - return (0, 0.0); - } - - let ms = max_shift as i32; - let mut best_d = 0i32; - let mut best_r = f32::NEG_INFINITY; - let mut r_at_zero = 0.0f32; - - for d in -ms..=ms { - let start = (spl as i32 + d) as usize; - let candidate = &buffer[start..start + spl]; - - let mean_c = candidate.iter().sum::() / n; - let mut var_c = 0.0f32; - let mut cov = 0.0f32; - for (i, &v) in candidate.iter().enumerate() { - let dr = reference[i] - mean_r; - let dc = v - mean_c; - cov += dr * dc; - var_c += dc * dc; - } - - let r = if var_c < MIN_VAR { - // Skip flat candidate slices. - f32::NEG_INFINITY - } else { - cov / (var_r.sqrt() * var_c.sqrt()) - }; - - if d == 0 { - r_at_zero = r; - } - if r > best_r { - best_r = r; - best_d = d; - } - } - - // Deadband: if the peak is only marginally better than `d = 0`, - // stick with zero. This avoids per-line jitter when drift is small. - const DEADBAND: f32 = 0.01; - if r_at_zero.is_finite() && best_r - r_at_zero < DEADBAND { - return (0, r_at_zero); - } - - (best_d, best_r) -} - #[cfg(test)] mod tests { use super::*; @@ -277,8 +119,7 @@ mod tests { let spl = WefaxConfig::samples_per_line(lpm, sr); let ppl = WefaxConfig::pixels_per_line(ioc) as usize; - // Slant correction off for deterministic line count. - let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, false); + let mut slicer = LineSlicer::new(lpm, ioc, sr, 0); // Feed exactly 3 lines worth of white. let samples = vec![1.0f32; spl * 3]; let lines = slicer.process(&samples); @@ -295,7 +136,7 @@ mod tests { let sr = 11025; let spl = WefaxConfig::samples_per_line(lpm, sr); - let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, false); + let mut slicer = LineSlicer::new(lpm, ioc, sr, 0); // Feed a linear ramp from 0.0 to 1.0. let samples: Vec = (0..spl).map(|i| i as f32 / spl as f32).collect(); let lines = slicer.process(&samples); @@ -304,92 +145,4 @@ mod tests { assert!(lines[0][0] < 5); assert!(lines[0].last().copied().unwrap_or(0) > 250); } - - /// Synthesise a noisy-ish gradient line that repeats with a small - /// per-line offset, simulating a sample-clock mismatch. The slant - /// tracker should follow the drift. - #[test] - fn slant_tracker_follows_drift() { - let lpm = 120; - let ioc = 576; - let sr = 11025; - let spl = WefaxConfig::samples_per_line(lpm, sr); - - // Build a signal where each real line is `spl + 3` samples long - // (i.e. transmitter clock is slower than expected → positive drift - // of +3 samples per line). The content needs high-frequency - // structure for a few-sample shift to be detectable against the - // deadband. - let true_line_len = spl + 3; - let mut signal: Vec = Vec::new(); - let base: Vec = (0..true_line_len) - .map(|i| { - // Pseudo-random-but-repeatable content with a narrow - // bright stripe — sharp features make sub-line shifts - // easy to localise. - let x = ((i as u32).wrapping_mul(2654435761)) >> 16; - let noise = (x & 0xff) as f32 / 255.0; - let stripe = if i == true_line_len / 3 { 1.0 } else { 0.0 }; - 0.3 + 0.4 * noise + stripe - }) - .collect(); - // 20 lines, each identical. - for _ in 0..20 { - signal.extend_from_slice(&base); - } - - let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, true); - let lines = slicer.process(&signal); - - // Expect ~ (20*true_line_len - spl) / (spl+drift) lines with - // drift absorbing the extra 2 samples per line. - assert!( - lines.len() >= 15, - "slant-corrected slicer produced only {} lines", - lines.len() - ); - // Should have tracked positive drift. - assert!( - slicer.total_drift > 0, - "expected positive drift, got {}", - slicer.total_drift - ); - // Roughly +3 per line (after the first bootstrap line); allow wide tolerance. - let per_line = slicer.total_drift as f32 / (lines.len() - 1) as f32; - assert!( - per_line > 1.5 && per_line < 4.0, - "per-line drift {:.2} out of range (total {}, lines {})", - per_line, - slicer.total_drift, - lines.len() - ); - } - - #[test] - fn slant_tracker_deadband_on_no_drift() { - let lpm = 120; - let ioc = 576; - let sr = 11025; - let spl = WefaxConfig::samples_per_line(lpm, sr); - - // Perfectly aligned lines → drift should stay at zero. - let line: Vec = (0..spl) - .map(|i| { - let t = i as f32 / spl as f32; - 0.5 + 0.4 * (t * 9.0 * std::f32::consts::PI).sin() - }) - .collect(); - let mut signal = Vec::new(); - for _ in 0..10 { - signal.extend_from_slice(&line); - } - - let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, true); - let _ = slicer.process(&signal); - // Deadband should keep drift at 0. - assert_eq!( - slicer.total_drift, 0, - "no drift expected for identical lines" - ); - } }