Initial commit
Sync docs to Wiki / wiki (push) Has been cancelled

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-05-17 23:25:14 +02:00
commit ba48de2d30
237 changed files with 105505 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
[package]
name = "trx-wefax"
version.workspace = true
edition = "2021"
[dependencies]
trx-core = { path = "../../trx-core" }
base64 = "0.22"
png = "0.17"
tracing = "0.1"
+52
View File
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! WEFAX decoder configuration.
/// Configuration for the WEFAX decoder.
#[derive(Debug, Clone)]
pub struct WefaxConfig {
/// Lines per minute: 60, 90, 120, 240. `None` = auto-detect from APT.
pub lpm: Option<u16>,
/// Index of Cooperation: 288 or 576. `None` = auto-detect from start tone.
pub ioc: Option<u16>,
/// Centre frequency of the FM subcarrier (default 1900 Hz).
pub center_freq_hz: f32,
/// Deviation (default ±400 Hz, so black=1500, white=2300).
pub deviation_hz: f32,
/// Directory for saving decoded images.
pub output_dir: Option<String>,
/// Whether to emit line-by-line progress events.
pub emit_progress: bool,
}
impl Default for WefaxConfig {
fn default() -> Self {
Self {
lpm: None,
ioc: None,
center_freq_hz: 1900.0,
deviation_hz: 400.0,
output_dir: None,
emit_progress: true,
}
}
}
impl WefaxConfig {
/// Pixels per line for a given IOC value: `IOC × π`, rounded.
pub fn pixels_per_line(ioc: u16) -> u16 {
(f64::from(ioc) * std::f64::consts::PI).round() as u16
}
/// Line duration in seconds for a given LPM value.
pub fn line_duration_s(lpm: u16) -> f32 {
60.0 / lpm as f32
}
/// Samples per line at the internal sample rate.
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
(Self::line_duration_s(lpm) * sample_rate as f32).round() as usize
}
}
+599
View File
@@ -0,0 +1,599 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Top-level WEFAX decoder state machine.
//!
//! Drives the DSP pipeline: resampler → FM discriminator → tone detector →
//! phasing → line slicer → image assembler.
use std::path::PathBuf;
use base64::Engine;
use trx_core::decode::{WefaxMessage, WefaxProgress};
use tracing::{debug, trace};
use crate::config::WefaxConfig;
use crate::demod::FmDiscriminator;
use crate::image::ImageAssembler;
use crate::line_slicer::LineSlicer;
use crate::phase::PhasingDetector;
use crate::resampler::{Resampler, INTERNAL_RATE};
use crate::tone_detect::{AptTone, ToneDetector};
/// Progress events are emitted every this many lines.
const PROGRESS_INTERVAL: u32 = 5;
/// Minimum luminance standard deviation to consider a window as containing
/// active WEFAX signal (image data has varied luminance; silence/noise is flat).
const SIGNAL_DETECT_MIN_STDDEV: f32 = 0.08;
/// Number of consecutive active-signal windows needed to auto-start receiving.
/// At 0.5 s per window this is ~3 seconds.
const SIGNAL_DETECT_WINDOWS: u32 = 6;
/// Pearson correlation below which a new scan line is considered uncorrelated
/// with its predecessor — i.e. the slicer is looking at noise, not imagery.
/// Real WEFAX content typically shows r > 0.5 between adjacent lines.
const LINE_CORR_NOISE_THRESHOLD: f32 = 0.2;
/// Number of consecutive uncorrelated scan lines that trigger auto-finalize
/// while receiving. At 120 LPM this is 15 s; at 60 LPM it's 30 s. Modelled on
/// fldigi's line-to-line correlation check for automatic stop.
const LINE_CORR_NOISE_LINES: u32 = 30;
/// Maximum number of scan-line-equivalent sample windows to wait for phasing
/// 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.
#[derive(Debug)]
pub enum WefaxEvent {
/// A progress update with line data for live rendering.
Progress(WefaxProgress, Vec<u8>),
/// A completed image.
Complete(WefaxMessage),
}
/// Internal decoder state.
#[derive(Debug, Clone, PartialEq, Eq)]
enum State {
/// Listening for APT start tone.
Idle,
/// Start tone detected; waiting for phasing signal.
StartDetected { ioc: u16 },
/// Receiving phasing lines; aligning line-start phase.
Phasing { ioc: u16, lpm: u16 },
/// Actively decoding image lines.
Receiving { ioc: u16, lpm: u16 },
/// Stop tone detected; finalising image.
Stopping { ioc: u16, lpm: u16 },
}
/// Top-level WEFAX decoder.
pub struct WefaxDecoder {
config: WefaxConfig,
state: State,
resampler: Resampler,
demodulator: FmDiscriminator,
tone_detector: ToneDetector,
phasing: Option<PhasingDetector>,
slicer: Option<LineSlicer>,
image: Option<ImageAssembler>,
/// Total sample counter for timestamps.
sample_count: u64,
/// Timestamp (ms since epoch) when reception started.
reception_start_ms: Option<i64>,
/// Whether the initial "Idle" state event has been emitted.
sent_idle_event: bool,
/// Counts consecutive half-second windows where the luminance variance is
/// high enough to indicate an active WEFAX transmission. Used to auto-start
/// receiving when tuning in mid-image (same idea as fldigi's "strong image
/// signal" detection in `fax_signal`).
signal_detect_count: u32,
/// Accumulator for computing luminance variance within the current window.
signal_detect_buf: Vec<f32>,
/// Counts consecutive scan lines whose correlation with the previous
/// line falls below `LINE_CORR_NOISE_THRESHOLD`. When it reaches
/// `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,
/// 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 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,
/// Current rig mode name (for image filenames).
mode: String,
}
impl WefaxDecoder {
pub fn new(input_sample_rate: u32, config: WefaxConfig) -> Self {
Self {
resampler: Resampler::new(input_sample_rate),
demodulator: FmDiscriminator::new(
INTERNAL_RATE,
config.center_freq_hz,
config.deviation_hz,
),
tone_detector: ToneDetector::new(INTERNAL_RATE),
config,
state: State::Idle,
phasing: None,
slicer: None,
image: None,
sample_count: 0,
reception_start_ms: None,
sent_idle_event: false,
signal_detect_count: 0,
signal_detect_buf: Vec::with_capacity(INTERNAL_RATE as usize / 2),
low_corr_lines: 0,
phasing_samples: 0,
freq_hz: 0,
mode: String::new(),
}
}
/// Process a block of PCM audio samples (mono, at the input sample rate).
///
/// Returns any events generated during processing.
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<WefaxEvent> {
self.sample_count += samples.len() as u64;
let mut events = Vec::new();
// Emit an initial "Idle" state event so the frontend knows the decoder is processing audio.
if !self.sent_idle_event {
self.sent_idle_event = true;
let ioc = self.config.ioc.unwrap_or(576);
let lpm = self.config.lpm.unwrap_or(120);
events.push(self.state_event("Idle \u{2014} scanning", ioc, lpm));
}
// Step 1: Resample to internal rate.
let resampled = self.resampler.process(samples);
// Step 2: FM demodulate to get luminance values.
let luminance = self.demodulator.process(&resampled);
// Periodic luminance stats for diagnostics (every ~5 seconds at 11025 Hz).
if self.sample_count % (INTERNAL_RATE as u64 * 5) < samples.len() as u64
&& !luminance.is_empty()
{
let min = luminance.iter().cloned().fold(f32::INFINITY, f32::min);
let max = luminance.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let mean = luminance.iter().sum::<f32>() / luminance.len() as f32;
trace!(
min = format!("{:.3}", min),
max = format!("{:.3}", max),
mean = format!("{:.3}", mean),
n = luminance.len(),
state = ?self.state,
"WEFAX luminance stats"
);
}
// Step 3: Run APT detector on demodulated luminance (transition counting).
let tone_results = self.tone_detector.process(&luminance);
// Step 4: Process based on current state.
match self.state.clone() {
State::Idle => {
// Look for APT start tone first.
for result in &tone_results {
if let Some(tone) = result.tone {
match tone {
AptTone::Start576 => {
events.push(self.transition_to_start_detected(576));
break;
}
AptTone::Start288 => {
events.push(self.transition_to_start_detected(288));
break;
}
AptTone::Stop => {} // Ignore stop in idle.
}
}
}
// Fallback: detect active WEFAX signal by luminance variance.
// Like fldigi's "strong image signal" detection — if we see
// sustained modulated signal, auto-start receiving with defaults.
if self.state == State::Idle {
self.signal_detect_buf.extend_from_slice(&luminance);
let window_size = INTERNAL_RATE as usize / 2;
while self.signal_detect_buf.len() >= window_size {
let window = &self.signal_detect_buf[..window_size];
let mean = window.iter().sum::<f32>() / window.len() as f32;
let variance = window
.iter()
.map(|&v| {
let d = v - mean;
d * d
})
.sum::<f32>()
/ window.len() as f32;
let stddev = variance.sqrt();
if stddev > SIGNAL_DETECT_MIN_STDDEV {
self.signal_detect_count += 1;
trace!(
stddev = format!("{:.4}", stddev),
count = self.signal_detect_count,
"WEFAX signal detected"
);
} else {
self.signal_detect_count = 0;
}
if self.signal_detect_count >= SIGNAL_DETECT_WINDOWS {
let ioc = self.config.ioc.unwrap_or(576);
let lpm = self.config.lpm.unwrap_or(120);
debug!(ioc, lpm, "WEFAX: auto-start from signal detection");
self.reception_start_ms = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64,
);
self.signal_detect_buf.clear();
events.push(self.transition_to_receiving(ioc, lpm, 0));
break;
}
self.signal_detect_buf.drain(..window_size);
}
}
}
State::StartDetected { ioc } => {
// Wait for tone to end (no more start tone detected), then
// transition to phasing.
let still_start = tone_results
.iter()
.any(|r| matches!(r.tone, Some(AptTone::Start576 | AptTone::Start288)));
if !still_start {
events.push(self.transition_to_phasing(ioc));
}
}
State::Phasing { ioc, lpm } => {
// Check for stop tone (abort).
if tone_results.iter().any(|r| r.tone == Some(AptTone::Stop)) {
self.transition_to_idle();
return events;
}
if let Some(ref mut phasing) = self.phasing {
if let Some(offset) = phasing.process(&luminance) {
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 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 {
debug!(
ioc,
lpm, "WEFAX: phasing timeout — falling through to receiving"
);
events.push(self.transition_to_receiving(ioc, lpm, 0));
}
}
}
}
State::Receiving { ioc, lpm } => {
// Check for stop tone.
if tone_results.iter().any(|r| r.tone == Some(AptTone::Stop)) {
self.state = State::Stopping { ioc, lpm };
events.extend(self.finalize_image(ioc, lpm));
self.transition_to_idle();
return events;
}
// Feed luminance to line slicer.
let mut carrier_lost = 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 {
// 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_NOISE_THRESHOLD {
self.low_corr_lines += 1;
trace!(
r = format!("{:.3}", r),
count = self.low_corr_lines,
"WEFAX low line-correlation"
);
} else {
self.low_corr_lines = 0;
}
}
// Flat lines (correlation == None) don't advance
// 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();
if self.low_corr_lines >= LINE_CORR_NOISE_LINES {
debug!(
lines = count,
"WEFAX: line correlation lost — auto-finalizing image"
);
carrier_lost = true;
break;
}
// Emit progress event.
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
let line_data =
image.last_line().map(|l| l.to_vec()).unwrap_or_default();
let b64 =
base64::engine::general_purpose::STANDARD.encode(&line_data);
events.push(WefaxEvent::Progress(
WefaxProgress {
rig_id: None,
line_count: count,
lpm,
ioc,
pixels_per_line: WefaxConfig::pixels_per_line(ioc),
line_data: Some(b64),
state: None,
},
line_data,
));
}
}
}
}
if carrier_lost {
events.extend(self.finalize_image(ioc, lpm));
self.transition_to_idle();
return events;
}
}
State::Stopping { .. } => {
// Already handled, transition back to idle.
self.transition_to_idle();
}
}
events
}
/// Reset the decoder. Saves the in-progress image (if any) before
/// returning to Idle. Returns any completion events produced.
pub fn reset(&mut self) -> Vec<WefaxEvent> {
let events = match self.state {
State::Receiving { ioc, lpm } | State::Phasing { ioc, lpm } => {
self.finalize_image(ioc, lpm)
}
_ => Vec::new(),
};
self.state = State::Idle;
self.resampler.reset();
self.demodulator.reset();
self.tone_detector.reset();
self.phasing = None;
self.slicer = None;
self.image = None;
self.sample_count = 0;
self.reception_start_ms = None;
self.sent_idle_event = false;
self.signal_detect_count = 0;
self.signal_detect_buf.clear();
self.low_corr_lines = 0;
self.phasing_samples = 0;
events
}
/// Update the current rig tuning (used for image filenames).
pub fn set_tuning(&mut self, freq_hz: u64, mode: &str) {
self.freq_hz = freq_hz;
self.mode = mode.to_string();
}
/// Check if the decoder is currently receiving an image.
pub fn is_receiving(&self) -> bool {
matches!(self.state, State::Phasing { .. } | State::Receiving { .. })
}
fn state_event(&self, label: &str, ioc: u16, lpm: u16) -> WefaxEvent {
WefaxEvent::Progress(
WefaxProgress {
rig_id: None,
line_count: 0,
lpm,
ioc,
pixels_per_line: WefaxConfig::pixels_per_line(ioc),
line_data: None,
state: Some(label.to_string()),
},
Vec::new(),
)
}
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
let ioc = self.config.ioc.unwrap_or(ioc);
debug!(ioc, "WEFAX: APT start detected");
self.state = State::StartDetected { ioc };
self.reception_start_ms = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64,
);
let lpm = self.config.lpm.unwrap_or(120);
self.state_event(&format!("APT Start {}", ioc), ioc, lpm)
}
fn transition_to_phasing(&mut self, ioc: u16) -> WefaxEvent {
let lpm = self.config.lpm.unwrap_or(120); // Default 120 LPM.
debug!(ioc, lpm, "WEFAX: entering phasing");
self.tone_detector.reset();
self.phasing = Some(PhasingDetector::new(lpm, INTERNAL_RATE));
self.demodulator.reset();
self.phasing_samples = 0;
self.state = State::Phasing { ioc, lpm };
self.state_event("Phasing", ioc, lpm)
}
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::new(lpm, ioc, INTERNAL_RATE, phase_offset));
self.image = Some(ImageAssembler::new(ppl));
self.tone_detector.reset();
self.low_corr_lines = 0;
self.state = State::Receiving { ioc, lpm };
self.state_event("Receiving", ioc, lpm)
}
fn transition_to_idle(&mut self) {
self.state = State::Idle;
self.phasing = None;
self.slicer = None;
// image is kept until finalize_image is called or next reception starts.
self.tone_detector.reset();
self.signal_detect_count = 0;
self.signal_detect_buf.clear();
self.low_corr_lines = 0;
self.phasing_samples = 0;
}
fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec<WefaxEvent> {
let mut events = Vec::new();
if let Some(ref image) = self.image {
if image.line_count() == 0 {
return events;
}
let ppl = WefaxConfig::pixels_per_line(ioc);
let mut path_str = None;
let mut png_data = None;
// Save PNG if output directory is configured.
if let Some(ref dir) = self.config.output_dir {
let output_path = PathBuf::from(dir);
match image.save_png(&output_path, self.freq_hz, &self.mode) {
Ok(p) => {
// Read back the PNG bytes for remote client transfer.
match std::fs::read(&p) {
Ok(bytes) => {
png_data =
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
Err(e) => {
eprintln!("WEFAX: failed to read PNG for transfer: {}", e);
}
}
path_str = Some(p.to_string_lossy().into_owned());
}
Err(e) => {
// Log the error but still emit the completion event.
eprintln!("WEFAX: failed to save PNG: {}", e);
}
}
}
events.push(WefaxEvent::Complete(WefaxMessage {
rig_id: None,
ts_ms: self.reception_start_ms,
line_count: image.line_count(),
lpm,
ioc,
pixels_per_line: ppl,
path: path_str,
png_data,
complete: true,
}));
}
self.image = None;
self.reception_start_ms = None;
events
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
/// Generate an FM-modulated WEFAX APT start signal.
///
/// The APT start signal alternates between black (1500 Hz) and white
/// (2300 Hz) at the given transition rate, FM-modulated onto the 1900 Hz
/// subcarrier.
fn generate_apt_start(trans_freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> {
let n = (sample_rate as f32 * duration_s) as usize;
let center = 1900.0f32;
let deviation = 400.0f32;
let mut phase = 0.0f64;
(0..n)
.map(|i| {
// Square wave modulation at trans_freq.
let t = i as f32 / sample_rate as f32;
let mod_sign = if (2.0 * PI * trans_freq * t).sin() >= 0.0 {
1.0
} else {
-1.0
};
let inst_freq = center + deviation * mod_sign;
phase += 2.0 * std::f64::consts::PI * inst_freq as f64 / sample_rate as f64;
phase.sin() as f32
})
.collect()
}
#[test]
fn decoder_starts_idle() {
let dec = WefaxDecoder::new(48000, WefaxConfig::default());
assert_eq!(dec.state, State::Idle);
assert!(!dec.is_receiving());
}
#[test]
fn decoder_detects_start_tone() {
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
// Feed 3 seconds of APT start signal (300 transitions/s, IOC 576)
// at internal sample rate (bypass resampler).
let signal = generate_apt_start(300.0, 11025, 3.0);
dec.process_samples(&signal);
assert!(
matches!(
dec.state,
State::StartDetected { ioc: 576 } | State::Phasing { ioc: 576, .. }
),
"state should be StartDetected or Phasing, got {:?}",
dec.state
);
}
#[test]
fn decoder_reset_returns_to_idle() {
let mut dec = WefaxDecoder::new(48000, WefaxConfig::default());
dec.state = State::Receiving { ioc: 576, lpm: 120 };
dec.reset();
assert_eq!(dec.state, State::Idle);
}
}
+196
View File
@@ -0,0 +1,196 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! FM discriminator for WEFAX demodulation.
//!
//! Computes instantaneous frequency from the analytic signal produced by a
//! Hilbert transform FIR, then maps the frequency to a 0.01.0 luminance
//! value (1500 Hz = black, 2300 Hz = white).
//!
//! Uses block-based linear processing for auto-vectorisation of the FIR
//! convolution, consistent with `docs/Optimization-Guidelines.md`.
use std::f32::consts::PI;
/// Number of taps for the Hilbert transform FIR.
const HILBERT_TAPS: usize = 65;
/// Half the Hilbert FIR length (group delay in samples).
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
/// FM discriminator producing luminance values from audio samples.
pub struct FmDiscriminator {
/// Hilbert FIR coefficients (odd-length, anti-symmetric).
hilbert_coeffs: [f32; HILBERT_TAPS],
/// Tail buffer: last `HILBERT_TAPS - 1` input samples from the previous
/// block (used to prime the next convolution without modular indexing).
tail: Vec<f32>,
/// Previous analytic signal sample for frequency differentiation.
prev_i: f32,
prev_q: f32,
/// Pre-computed constants.
inv_2pi_ts: f32,
black_hz: f32,
inv_range_hz: f32,
}
impl FmDiscriminator {
pub fn new(sample_rate: u32, center_hz: f32, deviation_hz: f32) -> Self {
let coeffs = design_hilbert_fir();
let sr = sample_rate as f32;
Self {
hilbert_coeffs: coeffs,
tail: vec![0.0; HILBERT_TAPS - 1],
prev_i: 0.0,
prev_q: 0.0,
inv_2pi_ts: sr / (2.0 * PI),
black_hz: center_hz - deviation_hz,
inv_range_hz: 1.0 / (2.0 * deviation_hz),
}
}
/// Process a block of real-valued audio samples, returning luminance
/// values in the range 0.0 (black / 1500 Hz) to 1.0 (white / 2300 Hz).
///
/// The Hilbert FIR is evaluated on a contiguous linear buffer
/// (`[tail | samples]`) so the inner loop uses straight indexing—no
/// modular arithmetic—and the compiler can auto-vectorise.
pub fn process(&mut self, samples: &[f32]) -> Vec<f32> {
let n = HILBERT_TAPS;
let half = HILBERT_DELAY;
let tail_len = n - 1;
// Build contiguous work buffer: [tail from previous block | new samples].
let work_len = tail_len + samples.len();
let mut work = Vec::with_capacity(work_len);
work.extend_from_slice(&self.tail);
work.extend_from_slice(samples);
let mut output = Vec::with_capacity(samples.len());
let coeffs = &self.hilbert_coeffs;
for i in 0..samples.len() {
// Linear FIR convolution — window is work[i..i+n].
let window = &work[i..i + n];
let mut q = 0.0f32;
for k in 0..n {
q += coeffs[k] * window[n - 1 - k];
}
// In-phase component is the delayed input (group delay = half).
let i_val = work[i + half];
// Instantaneous frequency via phase differentiation:
// f = |arg(z[n] · conj(z[n-1]))| / (2π·Ts)
let di = i_val * self.prev_i + q * self.prev_q;
let dq = q * self.prev_i - i_val * self.prev_q;
let freq = dq.atan2(di).abs() * self.inv_2pi_ts;
// Map frequency to luminance.
let lum = ((freq - self.black_hz) * self.inv_range_hz).clamp(0.0, 1.0);
output.push(lum);
self.prev_i = i_val;
self.prev_q = q;
}
// Save tail for next call.
self.tail.copy_from_slice(&work[work_len - tail_len..]);
output
}
pub fn reset(&mut self) {
self.tail.fill(0.0);
self.prev_i = 0.0;
self.prev_q = 0.0;
}
}
/// Design a Hilbert transform FIR filter (odd-length, type III).
///
/// The impulse response is: h[n] = 2/(πn) for odd n (relative to centre),
/// 0 for even n, windowed with a Blackman window.
fn design_hilbert_fir() -> [f32; HILBERT_TAPS] {
let num_taps = HILBERT_TAPS;
let mut coeffs = [0.0f32; HILBERT_TAPS];
let m = (num_taps - 1) as f64;
let mid = m / 2.0;
let mut i = 0;
while i < num_taps {
let n = i as f64 - mid;
let ni = n.round() as i64;
if ni != 0 && ni % 2 != 0 {
// Hilbert kernel: 2/(π·n) for odd offsets.
let h = 2.0 / (std::f64::consts::PI * n);
// Blackman window.
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
coeffs[i] = (h * w) as f32;
}
i += 1;
}
coeffs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discriminator_white_tone() {
// Feed a pure 2300 Hz tone, expect luminance ≈ 1.0.
let sr = 11025;
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
let n = 2000;
let tone: Vec<f32> = (0..n)
.map(|i| (2.0 * PI * 2300.0 * i as f32 / sr as f32).sin())
.collect();
let lum = disc.process(&tone);
// Skip initial transient (Hilbert FIR settling).
let tail = &lum[lum.len() / 2..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!(
(avg - 1.0).abs() < 0.05,
"expected ~1.0 for white tone, got {}",
avg
);
}
#[test]
fn discriminator_black_tone() {
// Feed a pure 1500 Hz tone, expect luminance ≈ 0.0.
let sr = 11025;
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
let n = 2000;
let tone: Vec<f32> = (0..n)
.map(|i| (2.0 * PI * 1500.0 * i as f32 / sr as f32).sin())
.collect();
let lum = disc.process(&tone);
let tail = &lum[lum.len() / 2..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!(avg < 0.05, "expected ~0.0 for black tone, got {}", avg);
}
#[test]
fn discriminator_center_tone() {
// Feed 1900 Hz (center), expect luminance ≈ 0.5.
let sr = 11025;
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
let n = 2000;
let tone: Vec<f32> = (0..n)
.map(|i| (2.0 * PI * 1900.0 * i as f32 / sr as f32).sin())
.collect();
let lum = disc.process(&tone);
let tail = &lum[lum.len() / 2..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!(
(avg - 0.5).abs() < 0.05,
"expected ~0.5 for center tone, got {}",
avg
);
}
}
+382
View File
@@ -0,0 +1,382 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Image buffer and PNG encoding for WEFAX decoded images.
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
/// Image assembler: accumulates greyscale lines and encodes to PNG.
pub struct ImageAssembler {
pixels_per_line: usize,
lines: Vec<Vec<u8>>,
}
impl ImageAssembler {
pub fn new(pixels_per_line: usize) -> Self {
Self {
pixels_per_line,
lines: Vec::with_capacity(800),
}
}
/// Append a completed greyscale line.
pub fn push_line(&mut self, line: Vec<u8>) {
debug_assert_eq!(line.len(), self.pixels_per_line);
self.lines.push(line);
}
/// Number of lines accumulated so far.
pub fn line_count(&self) -> u32 {
self.lines.len() as u32
}
/// Get the most recently added line (for progress events).
pub fn last_line(&self) -> Option<&[u8]> {
self.lines.last().map(|l| l.as_slice())
}
/// Pearson correlation between `line` and the most recently pushed line.
///
/// Returns `None` if there is no previous line, the lengths don't match,
/// or either line has near-zero variance (constant pixels — correlation
/// is undefined, and flat regions shouldn't be scored as "noise").
///
/// For real WEFAX image content adjacent lines are typically highly
/// correlated (r > 0.5). When the signal is lost and the slicer feeds
/// on noise, r collapses toward 0. This mirrors fldigi's line-to-line
/// correlation check for automatic stop.
pub fn correlation_with_last(&self, line: &[u8]) -> Option<f32> {
let prev = self.lines.last()?;
if prev.len() != line.len() || line.is_empty() {
return None;
}
let n = line.len() as f32;
let mean_a = prev.iter().map(|&v| v as f32).sum::<f32>() / n;
let mean_b = line.iter().map(|&v| v as f32).sum::<f32>() / n;
let mut cov = 0.0f32;
let mut var_a = 0.0f32;
let mut var_b = 0.0f32;
for (&a, &b) in prev.iter().zip(line.iter()) {
let da = a as f32 - mean_a;
let db = b as f32 - mean_b;
cov += da * db;
var_a += da * da;
var_b += db * db;
}
// Require some variance in both lines — flat regions are common in
// real imagery (solid black/white) and shouldn't be penalised.
const MIN_VAR: f32 = 32.0; // ~ stddev of 4 counts on 0..255 scale
if var_a < MIN_VAR || var_b < MIN_VAR {
return None;
}
Some(cov / (var_a.sqrt() * var_b.sqrt()))
}
/// Encode the accumulated image to an 8-bit greyscale PNG file.
///
/// Returns the full path to the saved file.
pub fn save_png(&self, output_dir: &Path, freq_hz: u64, mode: &str) -> Result<PathBuf, String> {
if self.lines.is_empty() {
return Err("no image lines to save".into());
}
// Detect row-length drift before handing bytes to the encoder.
// png::Writer only validates the total byte count, so if some
// rows were pushed at the wrong width the total could still
// match and the decoded image would be silently skewed.
let expected = self.pixels_per_line;
let mut bad_rows: usize = 0;
for (i, line) in self.lines.iter().enumerate() {
if line.len() != expected {
bad_rows += 1;
if bad_rows <= 3 {
warn!(
row = i,
got = line.len(),
expected,
"WEFAX: scan line has wrong width"
);
}
}
}
if bad_rows > 0 {
return Err(format!(
"{} scan line(s) have wrong width (expected {} px)",
bad_rows, expected
));
}
std::fs::create_dir_all(output_dir).map_err(|e| format!("create output dir: {}", e))?;
let filename = generate_filename(freq_hz, mode);
let path = output_dir.join(&filename);
// We already buffer the image rows into `img_data` below and
// write them in a single call, so a BufWriter adds no value.
// Using the bare `File` also lets us fsync explicitly below.
let file = std::fs::File::create(&path)
.map_err(|e| format!("create PNG file '{}': {}", path.display(), e))?;
let width = self.pixels_per_line as u32;
let height = self.lines.len() as u32;
let mut encoder = png::Encoder::new(&file, width, height);
encoder.set_color(png::ColorType::Grayscale);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| format!("write PNG header: {}", e))?;
// Write all rows.
let expected_bytes = (width as usize) * (height as usize);
let mut img_data = Vec::with_capacity(expected_bytes);
for line in &self.lines {
img_data.extend_from_slice(line);
}
debug_assert_eq!(img_data.len(), expected_bytes);
writer.write_image_data(&img_data).map_err(|e| {
format!(
"write PNG data ({} bytes, {}x{}): {}",
img_data.len(),
width,
height,
e
)
})?;
// Explicitly finish the writer (writes IEND). Relying on Drop
// alone swallows any I/O error and can yield a truncated file.
writer
.finish()
.map_err(|e| format!("finalize PNG: {}", e))?;
// Flush the underlying file so the data is durably on disk by
// the time we emit the WefaxEvent::Complete.
(&file)
.flush()
.map_err(|e| format!("flush PNG file: {}", e))?;
file.sync_all()
.map_err(|e| format!("sync PNG file: {}", e))?;
let file_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
debug!(
path = %path.display(),
width,
height,
bytes = file_size,
"WEFAX: saved PNG"
);
Ok(path)
}
pub fn reset(&mut self) {
self.lines.clear();
}
}
fn generate_filename(freq_hz: u64, mode: &str) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
// Convert to UTC datetime components manually (avoid chrono dependency).
let (year, month, day, hour, min, sec) = unix_to_utc(secs);
let freq_khz = freq_hz / 1000;
format!(
"{:04}-{:02}-{:02}_{:02}-{:02}-{:02}-{}_kHz_{}.png",
year, month, day, hour, min, sec, freq_khz, mode
)
}
/// Convert Unix timestamp to (year, month, day, hour, minute, second) in UTC.
fn unix_to_utc(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
let s = secs;
let sec = (s % 60) as u32;
let min = ((s / 60) % 60) as u32;
let hour = ((s / 3600) % 24) as u32;
let mut days = (s / 86400) as i64;
// Days since 1970-01-01.
let mut year = 1970u32;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let leap = is_leap(year);
let month_days = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 0u32;
for (i, &md) in month_days.iter().enumerate() {
if days < md as i64 {
month = i as u32 + 1;
break;
}
days -= md as i64;
}
let day = days as u32 + 1;
(year, month, day, hour, min, sec)
}
fn is_leap(y: u32) -> bool {
y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn correlation_identifies_noise_vs_image() {
let mut asm = ImageAssembler::new(256);
// No previous line.
assert!(asm.correlation_with_last(&[0u8; 256]).is_none());
// Flat line, then a gradient: first call has no reference.
let gradient: Vec<u8> = (0..256).map(|i| i as u8).collect();
asm.push_line(gradient.clone());
// Nearly identical line — correlation ≈ 1.
let near: Vec<u8> = (0..256).map(|i| i as u8).collect();
let r = asm.correlation_with_last(&near).expect("r");
assert!(r > 0.99, "identical lines should correlate: r={}", r);
// Pseudo-random noise vs gradient — correlation should be low.
let noise: Vec<u8> = (0..256)
.map(|i| ((i * 1103515245 + 12345) as u32 >> 8 & 0xff) as u8)
.collect();
let r = asm.correlation_with_last(&noise).expect("r");
assert!(
r.abs() < 0.3,
"noise vs gradient should not correlate: r={}",
r
);
// Flat line returns None (no variance).
assert!(asm.correlation_with_last(&[128u8; 256]).is_none());
}
#[test]
fn image_assembler_line_count() {
let mut asm = ImageAssembler::new(1809);
assert_eq!(asm.line_count(), 0);
asm.push_line(vec![128; 1809]);
assert_eq!(asm.line_count(), 1);
asm.push_line(vec![255; 1809]);
assert_eq!(asm.line_count(), 2);
}
#[test]
fn save_png_to_temp_dir() {
let mut asm = ImageAssembler::new(100);
for i in 0..50 {
let val = (i * 255 / 49) as u8;
asm.push_line(vec![val; 100]);
}
let dir = std::env::temp_dir().join("trx-wefax-test");
let result = asm.save_png(&dir, 7880000, "USB");
assert!(result.is_ok(), "save_png failed: {:?}", result.err());
let path = result.unwrap();
assert!(path.exists());
// Read the file back and verify it decodes as a valid 8-bit
// greyscale PNG of the expected size. This catches truncation
// or IHDR-vs-IDAT mismatches that file-existence alone misses.
let decoder = png::Decoder::new(std::fs::File::open(&path).unwrap());
let mut reader = decoder.read_info().expect("PNG header invalid");
let info = reader.info();
assert_eq!(info.width, 100);
assert_eq!(info.height, 50);
assert_eq!(info.color_type, png::ColorType::Grayscale);
assert_eq!(info.bit_depth, png::BitDepth::Eight);
let mut buf = vec![0; reader.output_buffer_size()];
reader.next_frame(&mut buf).expect("PNG data truncated");
assert_eq!(buf.len(), 100 * 50);
// Clean up.
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
/// Verify save_png survives realistic WEFAX dimensions (IOC 576 →
/// 1809 px wide, 800+ lines tall) and that every byte round-trips.
#[test]
fn save_png_realistic_dimensions() {
let ppl = crate::config::WefaxConfig::pixels_per_line(576) as usize;
let mut asm = ImageAssembler::new(ppl);
for y in 0..820u32 {
let row: Vec<u8> = (0..ppl)
.map(|x| ((x as u32 ^ y).wrapping_mul(17) & 0xff) as u8)
.collect();
asm.push_line(row);
}
let dir = std::env::temp_dir().join("trx-wefax-test-realistic");
let path = asm.save_png(&dir, 7880000, "USB").expect("save_png");
let bytes = std::fs::read(&path).expect("read back");
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "missing PNG magic");
// IEND chunk should be the last 12 bytes.
assert_eq!(&bytes[bytes.len() - 8..bytes.len() - 4], b"IEND");
let decoder = png::Decoder::new(&bytes[..]);
let mut reader = decoder.read_info().expect("decode header");
let info = reader.info();
assert_eq!(info.width, ppl as u32);
assert_eq!(info.height, 820);
let mut buf = vec![0; reader.output_buffer_size()];
reader.next_frame(&mut buf).expect("decode data");
assert_eq!(buf.len(), ppl * 820);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn unix_to_utc_epoch() {
let (y, m, d, h, mi, s) = unix_to_utc(0);
assert_eq!((y, m, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn unix_to_utc_known_date() {
// 2026-03-28T14:30:00 UTC = 1774718600 (approximately)
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
assert_eq!(y, 2026);
// Just verify reasonable values without asserting exact date.
assert!(m >= 1 && m <= 12);
assert!(d >= 1 && d <= 31);
assert!(h < 24);
assert!(mi < 60);
}
}
+20
View File
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! WEFAX (Weather Facsimile) decoder.
//!
//! Pure Rust implementation supporting 60/90/120/240 LPM, IOC 288 and 576,
//! with automatic APT tone detection and phase alignment.
pub mod config;
pub mod decoder;
pub mod demod;
pub mod image;
pub mod line_slicer;
pub mod phase;
pub mod resampler;
pub mod tone_detect;
pub use config::WefaxConfig;
pub use decoder::{WefaxDecoder, WefaxEvent};
+148
View File
@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Line slicer: pixel clock recovery and line buffer assembly.
//!
//! 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.
use crate::config::WefaxConfig;
/// Line slicer for WEFAX image assembly.
pub struct LineSlicer {
/// Samples per line at the internal sample rate.
samples_per_line: usize,
/// Pixels per line (IOC × π).
pixels_per_line: usize,
/// Phase offset in samples from the phasing detector.
phase_offset: usize,
/// Accumulated luminance samples.
buffer: Vec<f32>,
/// Whether we have aligned to the phase offset yet.
aligned: bool,
}
impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
Self {
samples_per_line,
pixels_per_line,
phase_offset,
buffer: Vec::with_capacity(samples_per_line * 2),
aligned: false,
}
}
/// Feed luminance samples and extract complete image lines.
///
/// Returns a vector of completed lines, each as a `Vec<u8>` of
/// greyscale pixel values (0255).
pub fn process(&mut self, lum_samples: &[f32]) -> Vec<Vec<u8>> {
self.buffer.extend_from_slice(lum_samples);
let mut lines = Vec::new();
// On first call, skip samples to align to the phase offset.
if !self.aligned {
if self.buffer.len() < self.phase_offset {
return lines;
}
self.buffer.drain(..self.phase_offset);
self.aligned = true;
}
// 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);
offset += self.samples_per_line;
}
if offset > 0 {
self.buffer.drain(..offset);
}
lines
}
pub fn pixels_per_line(&self) -> usize {
self.pixels_per_line
}
pub fn reset(&mut self) {
self.buffer.clear();
self.aligned = false;
}
/// Resample a line's worth of luminance samples to the target pixel count
/// using linear interpolation.
fn resample_line(&self, samples: &[f32]) -> Vec<u8> {
let n_samples = samples.len() as f32;
let n_pixels = self.pixels_per_line;
let mut pixels = Vec::with_capacity(n_pixels);
for px in 0..n_pixels {
// Map pixel index to sample position.
let pos = (px as f32 + 0.5) * n_samples / n_pixels as f32;
let idx = pos.floor() as usize;
let frac = pos - idx as f32;
let v = if idx + 1 < samples.len() {
samples[idx] * (1.0 - frac) + samples[idx + 1] * frac
} else if idx < samples.len() {
samples[idx]
} else {
0.0
};
pixels.push((v * 255.0).clamp(0.0, 255.0) as u8);
}
pixels
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slicer_extracts_correct_line_count() {
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line(lpm, sr);
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
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);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].len(), ppl);
// All pixels should be white (255).
assert!(lines[0].iter().all(|&p| p == 255));
}
#[test]
fn slicer_linear_interpolation() {
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line(lpm, sr);
let mut slicer = LineSlicer::new(lpm, ioc, sr, 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 lines = slicer.process(&samples);
assert_eq!(lines.len(), 1);
// First pixel should be near 0, last pixel near 255.
assert!(lines[0][0] < 5);
assert!(lines[0].last().copied().unwrap_or(0) > 250);
}
}
+187
View File
@@ -0,0 +1,187 @@
// 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 j in pulse_start..pulse_start + pw {
if j < spl {
line[j] = 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
);
}
}
+208
View File
@@ -0,0 +1,208 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! Polyphase rational resampler: 48000 Hz → 11025 Hz.
//!
//! Ratio: 11025/48000 = 147/640 (after GCD reduction).
//! Uses a polyphase FIR filter bank to avoid computing the full upsampled
//! signal, consistent with `docs/Optimization-Guidelines.md`.
//!
//! Block-based: builds a linear `[history | input]` work buffer so the inner
//! FIR convolution loop uses straight indexing (no modular arithmetic) and
//! benefits from auto-vectorisation.
/// Internal processing sample rate.
pub const INTERNAL_RATE: u32 = 11025;
/// Default input sample rate.
pub const DEFAULT_INPUT_RATE: u32 = 48000;
/// Polyphase rational resampler.
pub struct Resampler {
/// Interpolation factor (numerator of the ratio).
up: usize,
/// Decimation factor (denominator of the ratio).
down: usize,
/// Number of taps per polyphase sub-filter.
taps_per_phase: usize,
/// Polyphase filter bank: `up` sub-filters, each with `taps_per_phase` taps.
bank: Vec<Vec<f32>>,
/// Input history buffer (`taps_per_phase` samples from the previous block).
history: Vec<f32>,
/// Current phase accumulator (tracks position in the up-sampled domain).
phase: usize,
}
impl Resampler {
/// Create a resampler from `input_rate` to [`INTERNAL_RATE`].
pub fn new(input_rate: u32) -> Self {
let g = gcd(INTERNAL_RATE as usize, input_rate as usize);
let up = INTERNAL_RATE as usize / g;
let down = input_rate as usize / g;
// Design a low-pass FIR prototype for the upsampled rate.
// The upsampled rate is `input_rate * up`. The output is then
// decimated by `down`. The anti-alias cutoff should be at
// `min(input_rate, output_rate) / 2`, which in normalized terms
// (relative to the upsampled rate) is `0.5 / max(up, down)`.
// Use 0.45 instead of 0.5 for transition band headroom.
let num_taps = up * 16 + 1; // ~16 taps per phase
let cutoff = 0.5 / (up.max(down) as f64);
let prototype = design_lowpass(num_taps, cutoff, up as f64);
// Split prototype into polyphase bank.
let taps_per_phase = prototype.len().div_ceil(up);
let mut bank = vec![vec![0.0f32; taps_per_phase]; up];
for (i, &coeff) in prototype.iter().enumerate() {
let phase = i % up;
let tap = i / up;
bank[phase][tap] = coeff;
}
// Normalize: each output sample comes from one sub-filter convolved
// with the input history. For unity DC gain, each sub-filter's sum
// must equal 1.0.
for sub in &mut bank {
let sub_sum: f64 = sub.iter().map(|&c| c as f64).sum();
if sub_sum.abs() > 1e-12 {
let scale = (1.0 / sub_sum) as f32;
for c in sub.iter_mut() {
*c *= scale;
}
}
}
let history = vec![0.0f32; taps_per_phase];
Self {
up,
down,
taps_per_phase,
bank,
history,
phase: 0,
}
}
/// Process a block of input samples, returning resampled output.
///
/// Uses a linear `[history | input]` work buffer so the inner FIR
/// convolution runs on contiguous memory with plain indexing.
#[allow(clippy::needless_range_loop)]
pub fn process(&mut self, input: &[f32]) -> Vec<f32> {
let tpp = self.taps_per_phase;
let mut output = Vec::with_capacity(input.len() * self.up / self.down + 2);
// Contiguous work buffer: [previous history | new input].
let mut work = Vec::with_capacity(tpp + input.len());
work.extend_from_slice(&self.history);
work.extend_from_slice(input);
for p in 0..input.len() {
// Generate output samples for all phases that map to this input.
while self.phase < self.up {
let coeffs = &self.bank[self.phase];
let mut acc = 0.0f32;
// Newest sample is at work[p + tpp], oldest at work[p + 1].
// coeffs[k] corresponds to the (k+1)-th newest sample.
for k in 0..tpp {
acc += coeffs[k] * work[p + tpp - k];
}
output.push(acc);
self.phase += self.down;
}
self.phase -= self.up;
}
// Save last `tpp` samples as history for next block.
let work_len = work.len();
self.history.copy_from_slice(&work[work_len - tpp..]);
output
}
/// Reset internal state (call on frequency change / decoder reset).
pub fn reset(&mut self) {
self.history.fill(0.0);
self.phase = 0;
}
}
/// Design a windowed-sinc low-pass FIR filter.
#[allow(clippy::needless_range_loop)]
fn design_lowpass(num_taps: usize, cutoff: f64, gain: f64) -> Vec<f32> {
let mut coeffs = vec![0.0f32; num_taps];
let m = num_taps as f64 - 1.0;
let mid = m / 2.0;
for i in 0..num_taps {
let n = i as f64 - mid;
// Sinc function.
let sinc = if n.abs() < 1e-12 {
2.0 * std::f64::consts::PI * cutoff
} else {
(2.0 * std::f64::consts::PI * cutoff * n).sin() / n
};
// Blackman window.
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
coeffs[i] = (sinc * w * gain) as f32;
}
coeffs
}
fn gcd(mut a: usize, mut b: usize) -> usize {
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resampler_ratio_48k_to_11025() {
let r = Resampler::new(48000);
// Feed 48000 samples, should get ~11025 out.
let input: Vec<f32> = vec![0.0; 48000];
let output = r.clone_and_process(&input);
// Allow ±2 samples tolerance for edge effects.
assert!(
(output.len() as i64 - 11025).unsigned_abs() <= 2,
"expected ~11025 samples, got {}",
output.len()
);
}
#[test]
fn resampler_dc_passthrough() {
let mut r = Resampler::new(48000);
// DC signal should pass through with unity gain (after settling).
let input: Vec<f32> = vec![1.0; 4800];
let output = r.process(&input);
// Check last quarter of output is close to 1.0.
let tail = &output[output.len() * 3 / 4..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!((avg - 1.0).abs() < 0.02, "DC gain mismatch: avg = {}", avg);
}
impl Resampler {
fn clone_and_process(&self, input: &[f32]) -> Vec<f32> {
let mut r = Self {
up: self.up,
down: self.down,
taps_per_phase: self.taps_per_phase,
bank: self.bank.clone(),
history: self.history.clone(),
phase: self.phase,
};
r.process(input)
}
}
}
+268
View File
@@ -0,0 +1,268 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
//! APT tone detector for WEFAX start/stop signals.
//!
//! Detects three APT signals by counting black↔white transitions in the
//! **demodulated luminance** stream (0.01.0):
//! - 300 transitions/s: Start signal for IOC 576
//! - 675 transitions/s: Start signal for IOC 288
//! - 450 transitions/s: Stop signal (end of transmission)
//!
//! This matches the fldigi approach: the APT "tones" are not audio-frequency
//! tones but transition rates in the demodulated FM output.
use tracing::trace;
/// Detected APT tone type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AptTone {
/// Start tone for IOC 576 (300 transitions/s).
Start576,
/// Start tone for IOC 288 (675 transitions/s).
Start288,
/// Stop tone (450 transitions/s).
Stop,
}
impl AptTone {
/// Return the IOC value associated with this tone, if it's a start tone.
pub fn ioc(self) -> Option<u16> {
match self {
AptTone::Start576 => Some(576),
AptTone::Start288 => Some(288),
AptTone::Stop => None,
}
}
}
/// Result from the tone detector for a single analysis window.
#[derive(Debug, Clone)]
pub struct ToneDetectResult {
/// Which tone was detected, if any.
pub tone: Option<AptTone>,
/// Duration in seconds the tone has been sustained.
pub sustained_s: f32,
}
/// Luminance threshold above which a sample is considered "high" (white).
const HIGH_THRESHOLD: f32 = 0.84;
/// Luminance threshold below which a sample is considered "low" (black).
const LOW_THRESHOLD: f32 = 0.16;
/// Frequency tolerance for matching APT frequencies (Hz).
const FREQ_TOLERANCE: u32 = 10;
/// APT transition-counting detector operating on demodulated luminance.
///
/// Counts low→high transitions in half-second windows and compares the
/// resulting frequency against the three APT target frequencies.
pub struct ToneDetector {
sample_rate: u32,
/// Analysis window size in samples (~0.5 s).
window_size: usize,
/// Number of samples accumulated in the current window.
sample_count: usize,
/// Whether the signal is currently in the "high" state.
is_high: bool,
/// Number of low→high transitions in the current window.
transitions: u32,
/// Currently sustained tone and duration counter.
current_tone: Option<AptTone>,
sustained_windows: u32,
/// Minimum number of consecutive matching windows before confirming.
min_sustain_windows: u32,
}
impl ToneDetector {
pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
let window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
Self {
sample_rate,
window_size,
sample_count: 0,
is_high: false,
transitions: 0,
current_tone: None,
sustained_windows: 0,
min_sustain_windows,
}
}
/// Feed **demodulated luminance** samples (0.0 = black, 1.0 = white).
///
/// Returns detection results at the end of each analysis window.
pub fn process(&mut self, luminance: &[f32]) -> Vec<ToneDetectResult> {
let mut results = Vec::new();
for &s in luminance {
// Track low→high transitions with hysteresis.
if s > HIGH_THRESHOLD && !self.is_high {
self.is_high = true;
self.transitions += 1;
} else if s < LOW_THRESHOLD && self.is_high {
self.is_high = false;
}
self.sample_count += 1;
if self.sample_count >= self.window_size {
results.push(self.analyze_window());
self.sample_count = 0;
self.transitions = 0;
}
}
results
}
/// Check if a tone has been confirmed (sustained for the minimum duration).
pub fn confirmed_tone(&self) -> Option<AptTone> {
if self.sustained_windows >= self.min_sustain_windows {
self.current_tone
} else {
None
}
}
pub fn reset(&mut self) {
self.sample_count = 0;
self.transitions = 0;
self.is_high = false;
self.current_tone = None;
self.sustained_windows = 0;
}
fn analyze_window(&mut self) -> ToneDetectResult {
// Compute transition frequency: transitions per second.
let freq = self.transitions * self.sample_rate / self.sample_count.max(1) as u32;
let detected = classify_freq(freq);
if detected.is_some() || self.transitions > 50 {
trace!(
transitions = self.transitions,
freq_hz = freq,
detected = ?detected,
sustained = self.sustained_windows,
"APT tone analysis"
);
}
// Update sustained detection tracking.
if detected == self.current_tone && detected.is_some() {
self.sustained_windows += 1;
} else {
self.current_tone = detected;
self.sustained_windows = if detected.is_some() { 1 } else { 0 };
}
ToneDetectResult {
tone: self.confirmed_tone(),
sustained_s: self.sustained_windows as f32 * self.window_size as f32
/ self.sample_rate as f32,
}
}
}
/// Classify a measured transition frequency into an APT tone.
fn classify_freq(freq: u32) -> Option<AptTone> {
if freq.abs_diff(300) <= FREQ_TOLERANCE {
Some(AptTone::Start576)
} else if freq.abs_diff(675) <= FREQ_TOLERANCE {
Some(AptTone::Start288)
} else if freq.abs_diff(450) <= FREQ_TOLERANCE {
Some(AptTone::Stop)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
/// Generate a luminance signal that alternates between black and white
/// at the given transition frequency (transitions per second).
fn generate_apt_signal(trans_freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> {
let n = (sample_rate as f32 * duration_s) as usize;
(0..n)
.map(|i| {
// Square wave at trans_freq Hz: above 0 → white, below 0 → black.
let phase = (2.0 * PI * trans_freq * i as f32 / sample_rate as f32).sin();
if phase >= 0.0 {
1.0
} else {
0.0
}
})
.collect()
}
#[test]
fn detect_start_576_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let signal = generate_apt_signal(300.0, sr, 3.0);
let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start576));
assert!(confirmed, "should detect 300 Hz APT start for IOC 576");
}
#[test]
fn detect_start_288_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let signal = generate_apt_signal(675.0, sr, 3.0);
let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start288));
assert!(confirmed, "should detect 675 Hz APT start for IOC 288");
}
#[test]
fn detect_stop_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let signal = generate_apt_signal(450.0, sr, 3.0);
let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Stop));
assert!(confirmed, "should detect 450 Hz APT stop tone");
}
#[test]
fn no_false_detect_on_silence() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let silence = vec![0.5f32; sr as usize * 3]; // mid-grey, no transitions
let results = det.process(&silence);
assert!(
results.iter().all(|r| r.tone.is_none()),
"should not detect any tone on constant signal"
);
}
#[test]
fn no_false_detect_on_image_data() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
// Simulate random-ish image data (varying luminance, no consistent frequency).
let n = sr as usize * 3;
let signal: Vec<f32> = (0..n)
.map(|i| {
// Mix of frequencies that don't match any APT tone.
let t = i as f32 / sr as f32;
(0.5 + 0.3 * (2.0 * PI * 137.0 * t).sin() + 0.2 * (2.0 * PI * 523.0 * t).sin())
.clamp(0.0, 1.0)
})
.collect();
let results = det.process(&signal);
assert!(
results.iter().all(|r| r.tone.is_none()),
"should not detect APT tone in random image data"
);
}
}