[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:
Generated
+1
@@ -3267,6 +3267,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"png",
|
"png",
|
||||||
|
"tracing",
|
||||||
"trx-core",
|
"trx-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ edition = "2021"
|
|||||||
trx-core = { path = "../../trx-core" }
|
trx-core = { path = "../../trx-core" }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
png = "0.17"
|
png = "0.17"
|
||||||
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use std::path::PathBuf;
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use trx_core::decode::{WefaxMessage, WefaxProgress};
|
use trx_core::decode::{WefaxMessage, WefaxProgress};
|
||||||
|
|
||||||
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
use crate::config::WefaxConfig;
|
use crate::config::WefaxConfig;
|
||||||
use crate::demod::FmDiscriminator;
|
use crate::demod::FmDiscriminator;
|
||||||
use crate::image::ImageAssembler;
|
use crate::image::ImageAssembler;
|
||||||
@@ -23,6 +25,14 @@ use crate::tone_detect::{AptTone, ToneDetector};
|
|||||||
/// Progress events are emitted every this many lines.
|
/// Progress events are emitted every this many lines.
|
||||||
const PROGRESS_INTERVAL: u32 = 5;
|
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.
|
/// WEFAX decoder output event.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum WefaxEvent {
|
pub enum WefaxEvent {
|
||||||
@@ -64,6 +74,15 @@ pub struct WefaxDecoder {
|
|||||||
sample_count: u64,
|
sample_count: u64,
|
||||||
/// Timestamp (ms since epoch) when reception started.
|
/// Timestamp (ms since epoch) when reception started.
|
||||||
reception_start_ms: Option<i64>,
|
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 {
|
impl WefaxDecoder {
|
||||||
@@ -85,6 +104,9 @@ impl WefaxDecoder {
|
|||||||
image: None,
|
image: None,
|
||||||
sample_count: 0,
|
sample_count: 0,
|
||||||
reception_start_ms: None,
|
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;
|
self.sample_count += samples.len() as u64;
|
||||||
let mut events = Vec::new();
|
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.
|
// Step 1: Resample to internal rate.
|
||||||
let resampled = self.resampler.process(samples);
|
let resampled = self.resampler.process(samples);
|
||||||
|
|
||||||
// Step 2: FM demodulate to get luminance values.
|
// Step 2: FM demodulate to get luminance values.
|
||||||
let luminance = self.demodulator.process(&resampled);
|
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).
|
// Step 3: Run APT detector on demodulated luminance (transition counting).
|
||||||
let tone_results = self.tone_detector.process(&luminance);
|
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.
|
// ongoing transmissions where the start tone was missed.
|
||||||
if !got_start {
|
if !got_start {
|
||||||
if let Some(ref mut idle_ph) = self.idle_phasing {
|
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 } => {
|
State::StartDetected { ioc } => {
|
||||||
@@ -246,6 +338,9 @@ impl WefaxDecoder {
|
|||||||
self.image = None;
|
self.image = None;
|
||||||
self.sample_count = 0;
|
self.sample_count = 0;
|
||||||
self.reception_start_ms = None;
|
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.
|
/// 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 {
|
fn transition_to_start_detected(&mut self, ioc: u16) -> WefaxEvent {
|
||||||
let ioc = self.config.ioc.unwrap_or(ioc);
|
let ioc = self.config.ioc.unwrap_or(ioc);
|
||||||
|
debug!(ioc, "WEFAX: APT start detected");
|
||||||
self.state = State::StartDetected { ioc };
|
self.state = State::StartDetected { ioc };
|
||||||
self.reception_start_ms = Some(
|
self.reception_start_ms = Some(
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
@@ -286,6 +382,7 @@ impl WefaxDecoder {
|
|||||||
|
|
||||||
fn transition_to_phasing(&mut self, ioc: u16) -> WefaxEvent {
|
fn transition_to_phasing(&mut self, ioc: u16) -> WefaxEvent {
|
||||||
let lpm = self.config.lpm.unwrap_or(120); // Default 120 LPM.
|
let lpm = self.config.lpm.unwrap_or(120); // Default 120 LPM.
|
||||||
|
debug!(ioc, lpm, "WEFAX: entering phasing");
|
||||||
self.tone_detector.reset();
|
self.tone_detector.reset();
|
||||||
self.phasing = Some(PhasingDetector::new(lpm, INTERNAL_RATE));
|
self.phasing = Some(PhasingDetector::new(lpm, INTERNAL_RATE));
|
||||||
self.demodulator.reset();
|
self.demodulator.reset();
|
||||||
@@ -294,6 +391,7 @@ impl WefaxDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) -> WefaxEvent {
|
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;
|
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
|
||||||
self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset));
|
self.slicer = Some(LineSlicer::new(lpm, ioc, INTERNAL_RATE, phase_offset));
|
||||||
self.image = Some(ImageAssembler::new(ppl));
|
self.image = Some(ImageAssembler::new(ppl));
|
||||||
@@ -310,6 +408,8 @@ impl WefaxDecoder {
|
|||||||
// image is kept until finalize_image is called or next reception starts.
|
// image is kept until finalize_image is called or next reception starts.
|
||||||
self.tone_detector.reset();
|
self.tone_detector.reset();
|
||||||
self.idle_phasing = Some(PhasingDetector::new(default_lpm, INTERNAL_RATE));
|
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> {
|
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
|
//! This matches the fldigi approach: the APT "tones" are not audio-frequency
|
||||||
//! tones but transition rates in the demodulated FM output.
|
//! tones but transition rates in the demodulated FM output.
|
||||||
|
|
||||||
|
use tracing::trace;
|
||||||
|
|
||||||
/// Detected APT tone type.
|
/// Detected APT tone type.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AptTone {
|
pub enum AptTone {
|
||||||
@@ -76,7 +78,7 @@ pub struct ToneDetector {
|
|||||||
impl ToneDetector {
|
impl ToneDetector {
|
||||||
pub fn new(sample_rate: u32) -> Self {
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
|
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 window_duration_s = window_size as f32 / sample_rate as f32;
|
||||||
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
|
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
|
||||||
|
|
||||||
@@ -141,6 +143,16 @@ impl ToneDetector {
|
|||||||
|
|
||||||
let detected = classify_freq(freq);
|
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.
|
// Update sustained detection tracking.
|
||||||
if detected == self.current_tone && detected.is_some() {
|
if detected == self.current_tone && detected.is_some() {
|
||||||
self.sustained_windows += 1;
|
self.sustained_windows += 1;
|
||||||
|
|||||||
@@ -253,7 +253,8 @@ window.onServerWefaxProgress = function (msg) {
|
|||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = msg.state;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user