[fix](trx-wefax): revert slant correction and silent-drop verifier

Per-line cross-correlation slant tracking (d487711) shifted every line by up to \u00b16 samples with a 0.01 deadband, so image-content and shot-noise variance dominated the drift estimate and garbled the output. The unverified-reception verifier (76f9953) then silently dropped the entire capture at line 40 when correlation never settled. Together they made valid transmissions look like decoder failures.

Revert both: fixed-period extraction restored, carrier-loss watchdog ungated, transition_to_receiving no longer takes a verified flag. Phasing timeout fallback and variance-based auto-start kept. This returns the decoder to fldigi-equivalent behaviour.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-04-21 21:39:40 +02:00
parent 7178ebeb23
commit 1911a109e2
3 changed files with 37 additions and 373 deletions
-4
View File
@@ -19,9 +19,6 @@ pub struct WefaxConfig {
pub output_dir: Option<String>, pub output_dir: Option<String>,
/// Whether to emit line-by-line progress events. /// Whether to emit line-by-line progress events.
pub emit_progress: bool, 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 { impl Default for WefaxConfig {
@@ -33,7 +30,6 @@ impl Default for WefaxConfig {
deviation_hz: 400.0, deviation_hz: 400.0,
output_dir: None, output_dir: None,
emit_progress: true, emit_progress: true,
slant_correction: true,
} }
} }
} }
+25 -110
View File
@@ -43,26 +43,11 @@ const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// fldigi's line-to-line correlation check for automatic stop. /// fldigi's line-to-line correlation check for automatic stop.
const LINE_CORR_NOISE_LINES: u32 = 30; 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 /// Maximum number of scan-line-equivalent sample windows to wait for phasing
/// lock before falling through to Receiving (unverified). Typical WEFAX /// lock before falling through to Receiving. Typical WEFAX phasing lasts
/// phasing lasts ~30 s; if the phasing detector hasn't converged by then /// ~30 s; if the phasing detector hasn't converged by then we give up on
/// we give up on alignment and let the correlation verifier decide whether /// alignment and let the carrier-loss watchdog decide whether the content
/// the content that follows is a real image. At 120 LPM this is ~30 s. /// that follows is real imagery. At 120 LPM this is ~30 s.
const PHASING_TIMEOUT_LINES: u32 = 60; const PHASING_TIMEOUT_LINES: u32 = 60;
/// WEFAX decoder output event. /// WEFAX decoder output event.
@@ -117,18 +102,10 @@ pub struct WefaxDecoder {
/// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress /// `LINE_CORR_NOISE_LINES` the decoder auto-finalizes the in-progress
/// image (carrier dropped / tx ended without an APT stop tone). /// image (carrier dropped / tx ended without an APT stop tone).
low_corr_lines: u32, 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`. /// Number of luminance samples processed while in `State::Phasing`.
/// When this exceeds the equivalent of `PHASING_TIMEOUT_LINES` lines, /// When this exceeds the equivalent of `PHASING_TIMEOUT_LINES` lines,
/// the decoder falls through to Receiving (unverified) so a noisy or /// the decoder falls through to Receiving so a noisy or partial
/// partial phasing signal doesn't wedge the state machine. /// phasing signal doesn't wedge the state machine.
phasing_samples: u64, phasing_samples: u64,
/// Current rig dial frequency in Hz (for image filenames). /// Current rig dial frequency in Hz (for image filenames).
freq_hz: u64, freq_hz: u64,
@@ -157,8 +134,6 @@ impl WefaxDecoder {
signal_detect_count: 0, signal_detect_count: 0,
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2), signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0, low_corr_lines: 0,
verified: false,
high_corr_streak: 0,
phasing_samples: 0, phasing_samples: 0,
freq_hz: 0, freq_hz: 0,
mode: String::new(), mode: String::new(),
@@ -267,7 +242,7 @@ impl WefaxDecoder {
.as_millis() as i64, .as_millis() as i64,
); );
self.signal_detect_buf.clear(); 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; break;
} }
@@ -297,12 +272,12 @@ impl WefaxDecoder {
if let Some(ref mut phasing) = self.phasing { if let Some(ref mut phasing) = self.phasing {
if let Some(offset) = phasing.process(&luminance) { 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 { } else {
// Phasing timeout: if alignment doesn't converge in // Phasing timeout: if alignment doesn't converge in
// ~PHASING_TIMEOUT_LINES lines, fall through to // ~PHASING_TIMEOUT_LINES lines, fall through to
// Receiving (unverified) and let the correlation // Receiving and let the carrier-loss watchdog decide
// verifier decide. // whether the content that follows is real imagery.
self.phasing_samples += luminance.len() as u64; self.phasing_samples += luminance.len() as u64;
let spl = WefaxConfig::samples_per_line(lpm, INTERNAL_RATE) as u64; let spl = WefaxConfig::samples_per_line(lpm, INTERNAL_RATE) as u64;
if self.phasing_samples >= spl * PHASING_TIMEOUT_LINES as u64 { if self.phasing_samples >= spl * PHASING_TIMEOUT_LINES as u64 {
@@ -310,7 +285,7 @@ impl WefaxDecoder {
ioc, ioc,
lpm, "WEFAX: phasing timeout — falling through to receiving" 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. // Feed luminance to line slicer.
let mut carrier_lost = false; let mut carrier_lost = false;
let mut verify_failed = false;
if let Some(ref mut slicer) = self.slicer { if let Some(ref mut slicer) = self.slicer {
let new_lines = slicer.process(&luminance); let new_lines = slicer.process(&luminance);
for line in new_lines { for line in new_lines {
if let Some(ref mut image) = self.image { if let Some(ref mut image) = self.image {
// Line-to-line Pearson correlation classifies the // Carrier-loss watchdog: real imagery has highly
// new line as image-like, noise-like, or flat. // correlated adjacent lines; pure noise does not.
// fldigi-style: real imagery has highly correlated // After LINE_CORR_NOISE_LINES consecutive low-
// adjacent lines; pure noise does not. // correlation lines we finalize (fldigi-style
// automatic stop).
if let Some(r) = image.correlation_with_last(&line) { if let Some(r) = image.correlation_with_last(&line) {
if r >= LINE_CORR_IMAGE_THRESHOLD { if r < LINE_CORR_NOISE_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 {
self.low_corr_lines += 1; self.low_corr_lines += 1;
self.high_corr_streak = 0;
trace!( trace!(
r = format!("{:.3}", r), r = format!("{:.3}", r),
count = self.low_corr_lines, count = self.low_corr_lines,
"WEFAX low line-correlation" "WEFAX low line-correlation"
); );
} else { } else {
// Middle zone — reset high streak, hold self.low_corr_lines = 0;
// low-corr counter.
self.high_corr_streak = 0;
} }
} }
// Flat lines (correlation == None) don't advance // Flat lines (correlation == None) don't advance
// either counter — solid bands in real imagery // the counter but also don't reset it — an image
// shouldn't be scored as noise OR as evidence. // with a solid band surrounded by noise still
// trips the watchdog once the noise resumes.
image.push_line(line); image.push_line(line);
let count = image.line_count(); let count = image.line_count();
// Unverified timeout: if we got here from a if self.low_corr_lines >= LINE_CORR_NOISE_LINES {
// 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 {
debug!( debug!(
lines = count, lines = count,
"WEFAX: line correlation lost — auto-finalizing image" "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 { if carrier_lost {
events.extend(self.finalize_image(ioc, lpm)); events.extend(self.finalize_image(ioc, lpm));
self.transition_to_idle(); self.transition_to_idle();
@@ -465,8 +401,6 @@ impl WefaxDecoder {
self.signal_detect_count = 0; self.signal_detect_count = 0;
self.signal_detect_buf.clear(); self.signal_detect_buf.clear();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.verified = false;
self.high_corr_streak = 0;
self.phasing_samples = 0; self.phasing_samples = 0;
events events
} }
@@ -522,30 +456,13 @@ impl WefaxDecoder {
self.state_event("Phasing", ioc, lpm) self.state_event("Phasing", ioc, lpm)
} }
fn transition_to_receiving( fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) -> WefaxEvent {
&mut self, debug!(ioc, lpm, phase_offset, "WEFAX: entering receiving");
ioc: u16,
lpm: u16,
phase_offset: usize,
verified: bool,
) -> WefaxEvent {
debug!(
ioc,
lpm, phase_offset, verified, "WEFAX: entering receiving"
);
let ppl = WefaxConfig::pixels_per_line(ioc) as usize; let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
self.slicer = Some(LineSlicer::with_slant( self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset));
lpm,
ioc,
INTERNAL_RATE,
phase_offset,
self.config.slant_correction,
));
self.image = Some(ImageAssembler::new(ppl)); self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset(); self.tone_detector.reset();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.verified = verified;
self.high_corr_streak = 0;
self.state = State::Receiving { ioc, lpm }; self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm) self.state_event("Receiving", ioc, lpm)
} }
@@ -559,8 +476,6 @@ impl WefaxDecoder {
self.signal_detect_count = 0; self.signal_detect_count = 0;
self.signal_detect_buf.clear(); self.signal_detect_buf.clear();
self.low_corr_lines = 0; self.low_corr_lines = 0;
self.verified = false;
self.high_corr_streak = 0;
self.phasing_samples = 0; self.phasing_samples = 0;
} }
+12 -259
View File
@@ -7,21 +7,9 @@
//! Once the phasing detector has established a line-start phase offset, //! Once the phasing detector has established a line-start phase offset,
//! the line slicer accumulates demodulated luminance samples and extracts //! the line slicer accumulates demodulated luminance samples and extracts
//! complete image lines at the configured LPM rate. //! 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; 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. /// Line slicer for WEFAX image assembly.
pub struct LineSlicer { pub struct LineSlicer {
/// Samples per line at the internal sample rate. /// Samples per line at the internal sample rate.
@@ -30,34 +18,14 @@ pub struct LineSlicer {
pixels_per_line: usize, pixels_per_line: usize,
/// Phase offset in samples from the phasing detector. /// Phase offset in samples from the phasing detector.
phase_offset: usize, phase_offset: usize,
/// Accumulated luminance samples. While `slant_correction` is on, /// Accumulated luminance samples.
/// 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.
buffer: Vec<f32>, buffer: Vec<f32>,
/// Whether we have aligned to the phase offset yet. /// Whether we have aligned to the phase offset yet.
aligned: bool, 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 { impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self { 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 samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize; let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
@@ -65,11 +33,8 @@ impl LineSlicer {
samples_per_line, samples_per_line,
pixels_per_line, pixels_per_line,
phase_offset, phase_offset,
buffer: Vec::with_capacity(samples_per_line * 3), buffer: Vec::with_capacity(samples_per_line * 2),
aligned: false, aligned: false,
has_reference: false,
slant_correction,
total_drift: 0,
} }
} }
@@ -90,56 +55,16 @@ impl LineSlicer {
self.aligned = true; self.aligned = true;
} }
let spl = self.samples_per_line; // Extract complete lines (single drain at the end to avoid O(n²)).
let mut offset = 0;
if !self.slant_correction { while offset + self.samples_per_line <= self.buffer.len() {
// Simple fixed-period extraction. let line_samples = &self.buffer[offset..offset + self.samples_per_line];
let mut offset = 0; let pixels = self.resample_line(line_samples);
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);
lines.push(pixels); lines.push(pixels);
self.has_reference = true; offset += self.samples_per_line;
// Do NOT drain: the first `spl` samples remain as the
// reference for the next line's drift search.
} }
if offset > 0 {
// Subsequent lines: for each iteration, buffer[0..spl] is the self.buffer.drain(..offset);
// 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;
} }
lines lines
@@ -149,16 +74,9 @@ impl LineSlicer {
self.pixels_per_line 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) { pub fn reset(&mut self) {
self.buffer.clear(); self.buffer.clear();
self.aligned = false; self.aligned = false;
self.has_reference = false;
self.total_drift = 0;
} }
/// Resample a line's worth of luminance samples to the target pixel count /// 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::<f32>() / 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::<f32>() / 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -277,8 +119,7 @@ mod tests {
let spl = WefaxConfig::samples_per_line(lpm, sr); let spl = WefaxConfig::samples_per_line(lpm, sr);
let ppl = WefaxConfig::pixels_per_line(ioc) as usize; let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
// Slant correction off for deterministic line count. let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
let mut slicer = LineSlicer::with_slant(lpm, ioc, sr, 0, false);
// Feed exactly 3 lines worth of white. // Feed exactly 3 lines worth of white.
let samples = vec![1.0f32; spl * 3]; let samples = vec![1.0f32; spl * 3];
let lines = slicer.process(&samples); let lines = slicer.process(&samples);
@@ -295,7 +136,7 @@ mod tests {
let sr = 11025; let sr = 11025;
let spl = WefaxConfig::samples_per_line(lpm, sr); 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. // Feed a linear ramp from 0.0 to 1.0.
let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect(); let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect();
let lines = slicer.process(&samples); let lines = slicer.process(&samples);
@@ -304,92 +145,4 @@ mod tests {
assert!(lines[0][0] < 5); assert!(lines[0][0] < 5);
assert!(lines[0].last().copied().unwrap_or(0) > 250); 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<f32> = Vec::new();
let base: Vec<f32> = (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<f32> = (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"
);
}
} }