[fix](trx-wefax): detect APT tones from demodulated luminance, not raw audio

APT start/stop signals are not audio-frequency tones — they are
black↔white transition rates in the FM-demodulated output (300, 675,
450 transitions/s). The Goertzel detector was running on the raw ~1900 Hz
carrier where no energy exists at those frequencies, so APT detection
never fired on real HF WEFAX signals.

Replace the Goertzel approach with transition-counting on demodulated
luminance (matching fldigi's decode_apt), and swap the processing order
so FM demodulation runs before APT detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-04-03 22:44:27 +02:00
parent 96dcbbe8f1
commit a01609212e
2 changed files with 143 additions and 105 deletions
+29 -10
View File
@@ -98,12 +98,12 @@ impl WefaxDecoder {
// 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: Always run tone detector on raw resampled audio. // Step 2: FM demodulate to get luminance values.
let tone_results = self.tone_detector.process(&resampled);
// Step 3: FM demodulate to get luminance values.
let luminance = self.demodulator.process(&resampled); let luminance = self.demodulator.process(&resampled);
// 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. // Step 4: Process based on current state.
match self.state.clone() { match self.state.clone() {
State::Idle => { State::Idle => {
@@ -340,10 +340,29 @@ mod tests {
use super::*; use super::*;
use std::f32::consts::PI; use std::f32::consts::PI;
fn generate_tone(freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> { /// 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 n = (sample_rate as f32 * duration_s) as usize;
let center = 1900.0f32;
let deviation = 400.0f32;
let mut phase = 0.0f64;
(0..n) (0..n)
.map(|i| (2.0 * PI * freq * i as f32 / sample_rate as f32).sin()) .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() .collect()
} }
@@ -357,10 +376,10 @@ mod tests {
#[test] #[test]
fn decoder_detects_start_tone() { fn decoder_detects_start_tone() {
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default()); let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
// Feed 3 seconds of 300 Hz start tone directly at internal rate. // Feed 3 seconds of APT start signal (300 transitions/s, IOC 576)
// (bypass resampler by using internal rate as input rate) // at internal sample rate (bypass resampler).
let tone = generate_tone(300.0, 11025, 3.0); let signal = generate_apt_start(300.0, 11025, 3.0);
dec.process_samples(&tone); dec.process_samples(&signal);
assert!( assert!(
matches!(dec.state, State::StartDetected { ioc: 576 } | State::Phasing { ioc: 576, .. }), matches!(dec.state, State::StartDetected { ioc: 576 } | State::Phasing { ioc: 576, .. }),
"state should be StartDetected or Phasing, got {:?}", "state should be StartDetected or Phasing, got {:?}",
+114 -95
View File
@@ -2,23 +2,25 @@
// //
// SPDX-License-Identifier: BSD-2-Clause // SPDX-License-Identifier: BSD-2-Clause
//! Goertzel-based APT tone detector for WEFAX start/stop signals. //! APT tone detector for WEFAX start/stop signals.
//! //!
//! Detects three tones: //! Detects three APT signals by counting black↔white transitions in the
//! - 300 Hz: Start tone for IOC 576 //! **demodulated luminance** stream (0.01.0):
//! - 675 Hz: Start tone for IOC 288 //! - 300 transitions/s: Start signal for IOC 576
//! - 450 Hz: Stop tone (end of transmission) //! - 675 transitions/s: Start signal for IOC 288
//! - 450 transitions/s: Stop signal (end of transmission)
//! //!
//! Uses the same Goertzel pattern as `trx-cw`. //! This matches the fldigi approach: the APT "tones" are not audio-frequency
//! tones but transition rates in the demodulated FM output.
/// 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 {
/// Start tone for IOC 576 (300 Hz). /// Start tone for IOC 576 (300 transitions/s).
Start576, Start576,
/// Start tone for IOC 288 (675 Hz). /// Start tone for IOC 288 (675 transitions/s).
Start288, Start288,
/// Stop tone (450 Hz). /// Stop tone (450 transitions/s).
Stop, Stop,
} }
@@ -42,65 +44,74 @@ pub struct ToneDetectResult {
pub sustained_s: f32, pub sustained_s: f32,
} }
/// Goertzel tone detector for APT start/stop signals. /// 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 { pub struct ToneDetector {
sample_rate: f32, sample_rate: u32,
/// Goertzel analysis window size in samples (~200 ms). /// Analysis window size in samples (~0.5 s).
window_size: usize, window_size: usize,
/// Accumulated samples for the current window. /// Number of samples accumulated in the current window.
buffer: Vec<f32>, sample_count: usize,
/// Goertzel coefficients for each target frequency. /// Whether the signal is currently in the "high" state.
coeffs: [GoertzelCoeff; 3], is_high: bool,
/// Number of low→high transitions in the current window.
transitions: u32,
/// Currently sustained tone and duration counter. /// Currently sustained tone and duration counter.
current_tone: Option<AptTone>, current_tone: Option<AptTone>,
sustained_windows: u32, sustained_windows: u32,
/// Minimum sustained detection time in windows before confirming. /// Minimum number of consecutive matching windows before confirming.
min_sustain_windows: u32, min_sustain_windows: u32,
/// SNR threshold for tone detection (energy ratio vs broadband).
snr_threshold: f32,
}
struct GoertzelCoeff {
tone: AptTone,
coeff: f32, // 2 * cos(2π * freq / sample_rate * N) — but we use the standard form
#[allow(dead_code)]
freq: f32,
} }
impl ToneDetector { impl ToneDetector {
pub fn new(sample_rate: u32) -> Self { pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate as f32 * 0.2) as usize; // ~200 ms let window_size = (sample_rate / 2) as usize; // ~0.5 s window
let min_sustain_s = 1.5; let min_sustain_s = 1.5;
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;
let coeffs = [
GoertzelCoeff::new(AptTone::Start576, 300.0, sample_rate, window_size),
GoertzelCoeff::new(AptTone::Start288, 675.0, sample_rate, window_size),
GoertzelCoeff::new(AptTone::Stop, 450.0, sample_rate, window_size),
];
Self { Self {
sample_rate: sample_rate as f32, sample_rate,
window_size, window_size,
buffer: Vec::with_capacity(window_size), sample_count: 0,
coeffs, is_high: false,
transitions: 0,
current_tone: None, current_tone: None,
sustained_windows: 0, sustained_windows: 0,
min_sustain_windows, min_sustain_windows,
snr_threshold: 10.0, // tone must be 10× broadband energy
} }
} }
/// Feed audio samples (luminance values from FM discriminator are NOT /// Feed **demodulated luminance** samples (0.0 = black, 1.0 = white).
/// suitable; feed the raw resampled audio before demodulation). ///
pub fn process(&mut self, samples: &[f32]) -> Vec<ToneDetectResult> { /// Returns detection results at the end of each analysis window.
pub fn process(&mut self, luminance: &[f32]) -> Vec<ToneDetectResult> {
let mut results = Vec::new(); let mut results = Vec::new();
for &s in samples { for &s in luminance {
self.buffer.push(s); // Track low→high transitions with hysteresis.
if self.buffer.len() >= self.window_size { 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()); results.push(self.analyze_window());
self.buffer.clear(); self.sample_count = 0;
self.transitions = 0;
} }
} }
results results
@@ -116,29 +127,19 @@ impl ToneDetector {
} }
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.buffer.clear(); self.sample_count = 0;
self.transitions = 0;
self.is_high = false;
self.current_tone = None; self.current_tone = None;
self.sustained_windows = 0; self.sustained_windows = 0;
} }
fn analyze_window(&mut self) -> ToneDetectResult { fn analyze_window(&mut self) -> ToneDetectResult {
let samples = &self.buffer; // Compute transition frequency: transitions per second.
let freq =
self.transitions * self.sample_rate / self.sample_count.max(1) as u32;
// Compute broadband energy (RMS²). let detected = classify_freq(freq);
let broadband: f32 = samples.iter().map(|&s| s * s).sum::<f32>() / samples.len() as f32;
// Find the strongest tone above the SNR threshold.
let mut best: Option<(AptTone, f32)> = None;
for gc in &self.coeffs {
let energy = goertzel_energy(samples, gc.coeff);
let normalized = energy / samples.len() as f32;
if broadband > 1e-12 && normalized / broadband > self.snr_threshold
&& best.is_none_or(|(_, e)| normalized > e) {
best = Some((gc.tone, normalized));
}
}
let detected = best.map(|(tone, _)| tone);
// Update sustained detection tracking. // Update sustained detection tracking.
if detected == self.current_tone && detected.is_some() { if detected == self.current_tone && detected.is_some() {
@@ -151,43 +152,39 @@ impl ToneDetector {
ToneDetectResult { ToneDetectResult {
tone: self.confirmed_tone(), tone: self.confirmed_tone(),
sustained_s: self.sustained_windows as f32 * self.window_size as f32 sustained_s: self.sustained_windows as f32 * self.window_size as f32
/ self.sample_rate, / self.sample_rate as f32,
} }
} }
} }
impl GoertzelCoeff { /// Classify a measured transition frequency into an APT tone.
fn new(tone: AptTone, freq: f32, sample_rate: u32, window_size: usize) -> Self { fn classify_freq(freq: u32) -> Option<AptTone> {
let k = (freq * window_size as f32 / sample_rate as f32).round(); if freq.abs_diff(300) <= FREQ_TOLERANCE {
let coeff = 2.0 * (2.0 * std::f32::consts::PI * k / window_size as f32).cos(); Some(AptTone::Start576)
Self { tone, coeff, freq } } else if freq.abs_diff(675) <= FREQ_TOLERANCE {
Some(AptTone::Start288)
} else if freq.abs_diff(450) <= FREQ_TOLERANCE {
Some(AptTone::Stop)
} else {
None
} }
} }
/// Standard Goertzel algorithm returning magnitude² at the target bin.
fn goertzel_energy(samples: &[f32], coeff: f32) -> f32 {
let mut s1 = 0.0f32;
let mut s2 = 0.0f32;
for &x in samples {
let s0 = x + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
// Magnitude² = s1² + s2² - coeff·s1·s2
s1 * s1 + s2 * s2 - coeff * s1 * s2
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::f32::consts::PI; use std::f32::consts::PI;
fn generate_tone(freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> { /// 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; let n = (sample_rate as f32 * duration_s) as usize;
(0..n) (0..n)
.map(|i| (2.0 * PI * freq * i as f32 / sample_rate as f32).sin()) .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() .collect()
} }
@@ -195,41 +192,63 @@ mod tests {
fn detect_start_576_tone() { fn detect_start_576_tone() {
let sr = 11025; let sr = 11025;
let mut det = ToneDetector::new(sr); let mut det = ToneDetector::new(sr);
let tone = generate_tone(300.0, sr, 3.0); // 3 seconds of 300 Hz let signal = generate_apt_signal(300.0, sr, 3.0);
let results = det.process(&tone); let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start576)); let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start576));
assert!(confirmed, "should detect 300 Hz start tone for IOC 576"); assert!(confirmed, "should detect 300 Hz APT start for IOC 576");
} }
#[test] #[test]
fn detect_start_288_tone() { fn detect_start_288_tone() {
let sr = 11025; let sr = 11025;
let mut det = ToneDetector::new(sr); let mut det = ToneDetector::new(sr);
let tone = generate_tone(675.0, sr, 3.0); let signal = generate_apt_signal(675.0, sr, 3.0);
let results = det.process(&tone); let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start288)); let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start288));
assert!(confirmed, "should detect 675 Hz start tone for IOC 288"); assert!(confirmed, "should detect 675 Hz APT start for IOC 288");
} }
#[test] #[test]
fn detect_stop_tone() { fn detect_stop_tone() {
let sr = 11025; let sr = 11025;
let mut det = ToneDetector::new(sr); let mut det = ToneDetector::new(sr);
let tone = generate_tone(450.0, sr, 3.0); let signal = generate_apt_signal(450.0, sr, 3.0);
let results = det.process(&tone); let results = det.process(&signal);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Stop)); let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Stop));
assert!(confirmed, "should detect 450 Hz stop tone"); assert!(confirmed, "should detect 450 Hz APT stop tone");
} }
#[test] #[test]
fn no_false_detect_on_silence() { fn no_false_detect_on_silence() {
let sr = 11025; let sr = 11025;
let mut det = ToneDetector::new(sr); let mut det = ToneDetector::new(sr);
let silence = vec![0.0f32; sr as usize * 3]; let silence = vec![0.5f32; sr as usize * 3]; // mid-grey, no transitions
let results = det.process(&silence); let results = det.process(&silence);
assert!( assert!(
results.iter().all(|r| r.tone.is_none()), results.iter().all(|r| r.tone.is_none()),
"should not detect any tone in silence" "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"
); );
} }
} }