Files
trx-rs/src/decoders/trx-wefax/src/tone_detect.rs
T
sjg e5d8533a74 [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>
2026-04-03 23:13:07 +02:00

267 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! APT tone detector for WEFAX start/stop signals.
//!
//! Detects three APT signals by counting black↔white transitions in the
//! **demodulated luminance** stream (0.01.0):
//! - 300 transitions/s: Start signal for IOC 576
//! - 675 transitions/s: Start signal for IOC 288
//! - 450 transitions/s: Stop signal (end of transmission)
//!
//! This matches the fldigi approach: the APT "tones" are not audio-frequency
//! tones but transition rates in the demodulated FM output.
use tracing::trace;
/// Detected APT tone type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AptTone {
/// Start tone for IOC 576 (300 transitions/s).
Start576,
/// Start tone for IOC 288 (675 transitions/s).
Start288,
/// Stop tone (450 transitions/s).
Stop,
}
impl AptTone {
/// Return the IOC value associated with this tone, if it's a start tone.
pub fn ioc(self) -> Option<u16> {
match self {
AptTone::Start576 => Some(576),
AptTone::Start288 => Some(288),
AptTone::Stop => None,
}
}
}
/// Result from the tone detector for a single analysis window.
#[derive(Debug, Clone)]
pub struct ToneDetectResult {
/// Which tone was detected, if any.
pub tone: Option<AptTone>,
/// Duration in seconds the tone has been sustained.
pub sustained_s: f32,
}
/// Luminance threshold above which a sample is considered "high" (white).
const HIGH_THRESHOLD: f32 = 0.84;
/// Luminance threshold below which a sample is considered "low" (black).
const LOW_THRESHOLD: f32 = 0.16;
/// Frequency tolerance for matching APT frequencies (Hz).
const FREQ_TOLERANCE: u32 = 10;
/// APT transition-counting detector operating on demodulated luminance.
///
/// Counts low→high transitions in half-second windows and compares the
/// resulting frequency against the three APT target frequencies.
pub struct ToneDetector {
sample_rate: u32,
/// Analysis window size in samples (~0.5 s).
window_size: usize,
/// Number of samples accumulated in the current window.
sample_count: usize,
/// Whether the signal is currently in the "high" state.
is_high: bool,
/// Number of low→high transitions in the current window.
transitions: u32,
/// Currently sustained tone and duration counter.
current_tone: Option<AptTone>,
sustained_windows: u32,
/// Minimum number of consecutive matching windows before confirming.
min_sustain_windows: u32,
}
impl ToneDetector {
pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 1.0; // fldigi uses 2 consecutive half-second windows
let window_duration_s = window_size as f32 / sample_rate as f32;
let min_sustain_windows = (min_sustain_s / window_duration_s).ceil() as u32;
Self {
sample_rate,
window_size,
sample_count: 0,
is_high: false,
transitions: 0,
current_tone: None,
sustained_windows: 0,
min_sustain_windows,
}
}
/// Feed **demodulated luminance** samples (0.0 = black, 1.0 = white).
///
/// Returns detection results at the end of each analysis window.
pub fn process(&mut self, luminance: &[f32]) -> Vec<ToneDetectResult> {
let mut results = Vec::new();
for &s in luminance {
// Track low→high transitions with hysteresis.
if s > HIGH_THRESHOLD && !self.is_high {
self.is_high = true;
self.transitions += 1;
} else if s < LOW_THRESHOLD && self.is_high {
self.is_high = false;
}
self.sample_count += 1;
if self.sample_count >= self.window_size {
results.push(self.analyze_window());
self.sample_count = 0;
self.transitions = 0;
}
}
results
}
/// Check if a tone has been confirmed (sustained for the minimum duration).
pub fn confirmed_tone(&self) -> Option<AptTone> {
if self.sustained_windows >= self.min_sustain_windows {
self.current_tone
} else {
None
}
}
pub fn reset(&mut self) {
self.sample_count = 0;
self.transitions = 0;
self.is_high = false;
self.current_tone = None;
self.sustained_windows = 0;
}
fn analyze_window(&mut self) -> ToneDetectResult {
// Compute transition frequency: transitions per second.
let freq =
self.transitions * self.sample_rate / self.sample_count.max(1) as u32;
let detected = classify_freq(freq);
if detected.is_some() || self.transitions > 50 {
trace!(
transitions = self.transitions,
freq_hz = freq,
detected = ?detected,
sustained = self.sustained_windows,
"APT tone analysis"
);
}
// Update sustained detection tracking.
if detected == self.current_tone && detected.is_some() {
self.sustained_windows += 1;
} else {
self.current_tone = detected;
self.sustained_windows = if detected.is_some() { 1 } else { 0 };
}
ToneDetectResult {
tone: self.confirmed_tone(),
sustained_s: self.sustained_windows as f32 * self.window_size as f32
/ self.sample_rate as f32,
}
}
}
/// Classify a measured transition frequency into an APT tone.
fn classify_freq(freq: u32) -> Option<AptTone> {
if freq.abs_diff(300) <= FREQ_TOLERANCE {
Some(AptTone::Start576)
} else if freq.abs_diff(675) <= FREQ_TOLERANCE {
Some(AptTone::Start288)
} else if freq.abs_diff(450) <= FREQ_TOLERANCE {
Some(AptTone::Stop)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
/// Generate a luminance signal that alternates between black and white
/// at the given transition frequency (transitions per second).
fn generate_apt_signal(trans_freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> {
let n = (sample_rate as f32 * duration_s) as usize;
(0..n)
.map(|i| {
// Square wave at trans_freq Hz: above 0 → white, below 0 → black.
let phase = (2.0 * PI * trans_freq * i as f32 / sample_rate as f32).sin();
if phase >= 0.0 { 1.0 } else { 0.0 }
})
.collect()
}
#[test]
fn detect_start_576_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let signal = generate_apt_signal(300.0, sr, 3.0);
let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start576));
assert!(confirmed, "should detect 300 Hz APT start for IOC 576");
}
#[test]
fn detect_start_288_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let signal = generate_apt_signal(675.0, sr, 3.0);
let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start288));
assert!(confirmed, "should detect 675 Hz APT start for IOC 288");
}
#[test]
fn detect_stop_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let signal = generate_apt_signal(450.0, sr, 3.0);
let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Stop));
assert!(confirmed, "should detect 450 Hz APT stop tone");
}
#[test]
fn no_false_detect_on_silence() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let silence = vec![0.5f32; sr as usize * 3]; // mid-grey, no transitions
let results = det.process(&silence);
assert!(
results.iter().all(|r| r.tone.is_none()),
"should not detect any tone on constant signal"
);
}
#[test]
fn no_false_detect_on_image_data() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
// Simulate random-ish image data (varying luminance, no consistent frequency).
let n = sr as usize * 3;
let signal: Vec<f32> = (0..n)
.map(|i| {
// Mix of frequencies that don't match any APT tone.
let t = i as f32 / sr as f32;
(0.5 + 0.3 * (2.0 * PI * 137.0 * t).sin()
+ 0.2 * (2.0 * PI * 523.0 * t).sin())
.clamp(0.0, 1.0)
})
.collect();
let results = det.process(&signal);
assert!(
results.iter().all(|r| r.tone.is_none()),
"should not detect APT tone in random image data"
);
}
}