[feat](trx-wefax): auto-detect active signal and show live decode
Add signal-level detection that monitors luminance variance to auto-start receiving when tuning in mid-image (~3s of sustained modulated signal), matching fldigi's "strong image signal" detection. Reduce APT sustain to 1.0s (2 windows) matching fldigi. Emit initial "Idle — scanning" state event so the frontend shows the decoder is processing audio. Add tracing instrumentation for luminance stats and tone analysis. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -11,3 +11,4 @@ edition = "2021"
|
||||
trx-core = { path = "../../trx-core" }
|
||||
base64 = "0.22"
|
||||
png = "0.17"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -12,6 +12,8 @@ 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;
|
||||
@@ -23,6 +25,14 @@ 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;
|
||||
|
||||
/// WEFAX decoder output event.
|
||||
#[derive(Debug)]
|
||||
pub enum WefaxEvent {
|
||||
@@ -64,6 +74,15 @@ pub struct WefaxDecoder {
|
||||
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>,
|
||||
}
|
||||
|
||||
impl WefaxDecoder {
|
||||
@@ -85,6 +104,9 @@ impl WefaxDecoder {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +117,37 @@ impl WefaxDecoder {
|
||||
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);
|
||||
|
||||
@@ -129,7 +176,7 @@ impl WefaxDecoder {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try phasing detection on luminance to catch
|
||||
// Fallback 1: try phasing detection on luminance to catch
|
||||
// ongoing transmissions where the start tone was missed.
|
||||
if !got_start {
|
||||
if let Some(ref mut idle_ph) = self.idle_phasing {
|
||||
@@ -147,6 +194,51 @@ impl WefaxDecoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 2: 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.idle_phasing = None;
|
||||
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 } => {
|
||||
@@ -246,6 +338,9 @@ impl WefaxDecoder {
|
||||
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();
|
||||
}
|
||||
|
||||
/// Check if the decoder is currently receiving an image.
|
||||
@@ -273,6 +368,7 @@ impl WefaxDecoder {
|
||||
|
||||
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()
|
||||
@@ -286,6 +382,7 @@ impl WefaxDecoder {
|
||||
|
||||
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();
|
||||
@@ -294,6 +391,7 @@ impl WefaxDecoder {
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -310,6 +408,8 @@ impl WefaxDecoder {
|
||||
// image is kept until finalize_image is called or next reception starts.
|
||||
self.tone_detector.reset();
|
||||
self.idle_phasing = Some(PhasingDetector::new(default_lpm, INTERNAL_RATE));
|
||||
self.signal_detect_count = 0;
|
||||
self.signal_detect_buf.clear();
|
||||
}
|
||||
|
||||
fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec<WefaxEvent> {
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! 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 {
|
||||
@@ -76,7 +78,7 @@ pub struct ToneDetector {
|
||||
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.5;
|
||||
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;
|
||||
|
||||
@@ -141,6 +143,16 @@ impl ToneDetector {
|
||||
|
||||
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;
|
||||
|
||||
@@ -253,7 +253,8 @@ window.onServerWefaxProgress = function (msg) {
|
||||
if (msg.state && !msg.line_data) {
|
||||
if (wefaxDom.status) {
|
||||
wefaxDom.status.textContent = msg.state;
|
||||
wefaxDom.status.style.color = 'var(--text-accent)';
|
||||
// Highlight active states, dim idle/scanning.
|
||||
wefaxDom.status.style.color = msg.state.indexOf('Idle') === 0 ? '' : 'var(--text-accent)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user