Files
trx-rs/src/decoders/trx-wefax/src/phase.rs
T
sjg c6cd661676
CI / lint (pull_request) Failing after 4m14s
CI / test (pull_request) Successful in 15m22s
CI / reuse (pull_request) Successful in 7s
[style](trx-rs): fix clippy warnings for -D warnings CI
The CI lint job runs clippy with -D warnings, which surfaced a set of
existing warnings across decoders, the client, and the soapysdr backend.
Resolve them so the workspace is clean under the enforced lint level:

- collapsible_match / identity_op / needless_range_loop / same_item_push
  in trx-rds, trx-wspr, trx-vdes, trx-wefax, trx-aprs (mostly tests)
- field_reassign_with_default -> struct-update syntax in trx-client config
  tests
- assign_op_pattern, useless vec!, and test-module ordering picked up by
  cargo clippy --fix in trx-client and the soapysdr WFM tests

No behaviour changes; all affected crates' tests pass.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-07-17 23:49:12 +02:00

186 lines
5.9 KiB
Rust

// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Phasing signal detector and line-start alignment for WEFAX.
//!
//! During the phasing period, each line is >95% white (luminance ≈ 1.0) with
//! a narrow black pulse (~5% of line width) marking the line-start position.
//! This module detects the pulse position via cross-correlation against
//! a synthetic phasing template, and averages over multiple lines to
//! establish a stable phase offset.
use crate::config::WefaxConfig;
/// Minimum number of phasing lines needed to establish phase lock.
const MIN_PHASING_LINES: usize = 10;
/// Maximum variance (in samples²) of pulse position for phase to be considered stable.
const MAX_PHASE_VARIANCE: f32 = 16.0;
/// Fraction of line width occupied by the black pulse in phasing signal.
const PULSE_WIDTH_FRACTION: f32 = 0.05;
/// Phasing signal detector.
pub struct PhasingDetector {
samples_per_line: usize,
pulse_width: usize,
/// Collected pulse positions from each phasing line.
pub(crate) pulse_positions: Vec<usize>,
/// Luminance sample accumulator for the current line.
line_buffer: Vec<f32>,
/// Established phase offset (samples from buffer start to line start).
phase_offset: Option<usize>,
}
impl PhasingDetector {
pub fn new(lpm: u16, sample_rate: u32) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let pulse_width = (samples_per_line as f32 * PULSE_WIDTH_FRACTION).round() as usize;
Self {
samples_per_line,
pulse_width,
pulse_positions: Vec::new(),
line_buffer: Vec::with_capacity(samples_per_line),
phase_offset: None,
}
}
/// Feed luminance samples. Returns `Some(offset)` once phase is locked.
pub fn process(&mut self, lum_samples: &[f32]) -> Option<usize> {
if self.phase_offset.is_some() {
return self.phase_offset;
}
for &s in lum_samples {
self.line_buffer.push(s);
if self.line_buffer.len() >= self.samples_per_line {
self.analyze_phasing_line();
self.line_buffer.clear();
}
}
self.phase_offset
}
/// Return the established phase offset, if locked.
pub fn offset(&self) -> Option<usize> {
self.phase_offset
}
/// Check if phasing is complete and offset is stable.
pub fn is_locked(&self) -> bool {
self.phase_offset.is_some()
}
pub fn reset(&mut self) {
self.pulse_positions.clear();
self.line_buffer.clear();
self.phase_offset = None;
}
fn analyze_phasing_line(&mut self) {
let line = &self.line_buffer;
// Verify this looks like a phasing line: >90% should be high luminance.
let white_count = line.iter().filter(|&&v| v > 0.7).count();
if white_count < line.len() * 85 / 100 {
// Not a phasing line; reset accumulated positions.
self.pulse_positions.clear();
return;
}
// Find the black pulse position via minimum-energy sliding window.
let pw = self.pulse_width.max(1);
let mut min_energy = f32::MAX;
let mut min_pos = 0;
// Running sum for efficiency.
let mut sum: f32 = line[..pw].iter().sum();
if sum < min_energy {
min_energy = sum;
min_pos = 0;
}
for i in 1..=(line.len() - pw) {
sum += line[i + pw - 1] - line[i - 1];
if sum < min_energy {
min_energy = sum;
min_pos = i;
}
}
// The black pulse should be significantly darker than the average.
let avg_pulse = min_energy / pw as f32;
if avg_pulse > 0.3 {
// Pulse not dark enough, skip this line.
return;
}
// Record pulse position (centre of the pulse window).
self.pulse_positions.push(min_pos + pw / 2);
// Check if we have enough samples and the variance is low.
if self.pulse_positions.len() >= MIN_PHASING_LINES {
let mean = self.pulse_positions.iter().sum::<usize>() as f32
/ self.pulse_positions.len() as f32;
let variance = self
.pulse_positions
.iter()
.map(|&p| {
let d = p as f32 - mean;
d * d
})
.sum::<f32>()
/ self.pulse_positions.len() as f32;
if variance < MAX_PHASE_VARIANCE {
self.phase_offset = Some(mean.round() as usize);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_phasing_pulse() {
let lpm = 120;
let sr = 11025;
let spl = WefaxConfig::samples_per_line(lpm, sr);
let mut det = PhasingDetector::new(lpm, sr);
// Create 20 phasing lines with a black pulse at ~10% of line width.
let pw = (spl as f32 * PULSE_WIDTH_FRACTION).round() as usize;
let pulse_start = spl / 10;
let pulse_center = pulse_start + pw / 2;
for line_idx in 0..20 {
let mut line = vec![1.0f32; spl];
for slot in line.iter_mut().skip(pulse_start).take(pw) {
*slot = 0.0;
}
let result = det.process(&line);
if let Some(offset) = result {
assert!(
(offset as i32 - pulse_center as i32).unsigned_abs() <= 3,
"phase offset {} too far from expected {} (line {})",
offset,
pulse_center,
line_idx,
);
return;
}
}
panic!(
"phasing should have locked after 20 lines (spl={}, pw={}, positions={:?})",
spl, pw, det.pulse_positions
);
}
}