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>
186 lines
5.9 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|