[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:
@@ -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 {:?}",
|
||||||
|
|||||||
@@ -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.0–1.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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user