Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.0–1.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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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 (0–255).
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.0–1.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user