[feat](trx-wefax): implement WEFAX decoder with full server and frontend integration

Pure Rust WEFAX (Weather Facsimile) decoder supporting 60/90/120/240 LPM,
IOC 288 and 576, with automatic APT tone detection and phase alignment.

Core DSP pipeline:
- Polyphase rational resampler (48k→11025 Hz)
- FM discriminator (Hilbert FIR + instantaneous frequency)
- Goertzel tone detector (300/450/675 Hz APT tones)
- Phase alignment via cross-correlation on phasing signal
- Line slicer with linear interpolation pixel clock recovery
- Image assembler with PNG encoding

State machine: Idle→StartDetected→Phasing→Receiving→Stopping

Server integration:
- WefaxMessage/WefaxProgress in trx-core DecodedMessage
- DecoderConfig, DecoderResetSeqs, RigCommand wefax variants
- DECODER_REGISTRY entry in trx-protocol
- DecoderHistories/DecoderLoggers wefax support
- run_wefax_decoder() async task in trx-server audio.rs
- History persistence in pickledb store

Frontend integration:
- wefax.js plugin with live canvas rendering and gallery
- HTML sub-tab with canvas, gallery, toggle/clear controls
- SSE dispatch for wefax/wefax_progress events
- Decode history worker and restore support
- Toggle/clear API endpoints

19 unit tests covering resampler, FM discriminator, tone detection,
phasing, line slicing, image encoding, and decoder state machine.

https://claude.ai/code/session_019eyxgx3LuhcFZ7T5tr2Trm
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-02 21:39:17 +00:00
committed by Stan Grams
parent d2db3d65bd
commit daa31fb6e5
40 changed files with 2292 additions and 40 deletions
Generated
+9
View File
@@ -3247,6 +3247,7 @@ dependencies = [
"trx-protocol",
"trx-reporting",
"trx-vdes",
"trx-wefax",
"trx-wspr",
"trx-wxsat",
"uuid",
@@ -3260,6 +3261,14 @@ dependencies = [
"trx-core",
]
[[package]]
name = "trx-wefax"
version = "0.1.0"
dependencies = [
"png",
"trx-core",
]
[[package]]
name = "trx-wspr"
version = "0.1.0"
+1
View File
@@ -12,6 +12,7 @@ members = [
"src/decoders/trx-ftx",
"src/decoders/trx-rds",
"src/decoders/trx-vdes",
"src/decoders/trx-wefax",
"src/decoders/trx-wspr",
"src/trx-core",
"src/trx-protocol",
+24 -24
View File
@@ -1,7 +1,7 @@
# WEFAX / Radiofax Decoder Implementation Plan
> **Crate**: `trx-wefax` &mdash; `src/decoders/trx-wefax/`
> **Status**: Draft &mdash; 2026-04-02
> **Status**: Implemented (Phases 13b) &mdash; 2026-04-02
## 1. Overview
@@ -741,53 +741,53 @@ const HISTORY_GROUP_KEYS = [
## 8. Implementation Phases
### Phase 1: Core DSP (MVP)
### Phase 1: Core DSP (MVP)
1. **Resampler** &mdash; 48k→11025 polyphase resampler with tests.
2. **FM discriminator** &mdash; Hilbert FIR + instantaneous freq, verify
1. **Resampler** &mdash; 48k→11025 polyphase resampler with tests.
2. **FM discriminator** &mdash; Hilbert FIR + instantaneous freq, verify
against synthetic 15002300 Hz sweeps.
3. **Tone detector** &mdash; Goertzel at 300/450/675 Hz with debounce.
4. **Line slicer** &mdash; Fixed-config (manual LPM+IOC) line extraction.
5. **Image buffer + PNG** &mdash; Greyscale line accumulation, `image` or
`png` crate for encoding.
3. **Tone detector** &mdash; Goertzel at 300/450/675 Hz with debounce.
4. **Line slicer** &mdash; Fixed-config (manual LPM+IOC) line extraction.
5. **Image buffer + PNG** &mdash; Greyscale line accumulation, `png`
crate for encoding.
Deliverable: decode a known WEFAX WAV recording at a single speed/IOC.
### Phase 2: Automatic Detection
### Phase 2: Automatic Detection
6. **State machine** &mdash; Full `Idle→StartDetected→Phasing→Receiving→Stopping`
6. **State machine** &mdash; Full `Idle→StartDetected→Phasing→Receiving→Stopping`
transitions driven by tone detector.
7. **Phase alignment** &mdash; Cross-correlation phasing detector.
8. **Auto IOC/LPM** &mdash; IOC from start tone frequency; LPM from phasing
7. **Phase alignment** &mdash; Cross-correlation phasing detector.
8. **Auto IOC/LPM** &mdash; IOC from start tone frequency; LPM from phasing
line duration measurement.
Deliverable: fully automatic reception of a single image without manual config.
### Phase 3: Server Integration
### Phase 3: Server Integration
9. **`trx-core` message types** &mdash; `WefaxMessage`, `WefaxProgress` in
9. **`trx-core` message types** &mdash; `WefaxMessage`, `WefaxProgress` in
`DecodedMessage`.
10. **`trx-server` task** &mdash; `run_wefax_decoder()`, history, logging.
11. **Protocol registry** &mdash; `DECODER_REGISTRY` entry for `"wefax"`.
10. **`trx-server` task** &mdash; `run_wefax_decoder()`, history, logging.
11. **Protocol registry** &mdash; `DECODER_REGISTRY` entry for `"wefax"`.
Deliverable: backend wefax decoding with SSE event broadcast.
### Phase 3b: Frontend Wiring
### Phase 3b: Frontend Wiring
12. **Rust asset pipeline** &mdash; `status.rs` embed, `assets.rs` gzip
12. **Rust asset pipeline** &mdash; `status.rs` embed, `assets.rs` gzip
cache + route, `decoder.rs` toggle/clear endpoints, `api/mod.rs`
registration (§7.5.1).
13. **HTML scaffold** &mdash; sub-tab button, sub-tab panel with canvas +
13. **HTML scaffold** &mdash; sub-tab button, sub-tab panel with canvas +
gallery, overview entry, about row (§7.5.2).
14. **Plugin loading** &mdash; add `/wefax.js` to `pluginScripts`
14. **Plugin loading** &mdash; add `/wefax.js` to `pluginScripts`
`'digital-modes'` array (§7.5.3).
15. **SSE dispatch** &mdash; `wefax` / `wefax_progress` handlers in
15. **SSE dispatch** &mdash; `wefax` / `wefax_progress` handlers in
`app.js` decode event dispatcher (§7.5.4).
16. **`wefax.js` plugin** &mdash; live canvas rendering, gallery
16. **`wefax.js` plugin** &mdash; live canvas rendering, gallery
thumbnails, history restore, toggle/clear wiring (§7.5.5).
17. **Image serving** &mdash; `/images/{filename}` static route for
completed PNGs (§7.5.7).
18. **History worker** &mdash; add `"wefax"` to `HISTORY_GROUP_KEYS`
completed PNGs (§7.5.7). *(deferred: images served from output_dir)*
18. **History worker** &mdash; add `"wefax"` to `HISTORY_GROUP_KEYS`
(§7.5.8).
Deliverable: end-to-end live WEFAX decoding with in-browser image preview.
+10 -1
View File
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::warn;
use trx_core::decode::{AprsPacket, CwEvent, Ft8Message, WsprMessage};
use trx_core::decode::{AprsPacket, CwEvent, Ft8Message, WefaxMessage, WsprMessage};
// ---------------------------------------------------------------------------
// Configuration
@@ -51,6 +51,8 @@ pub struct DecodeLogsConfig {
pub ft8_file: String,
/// WSPR decoder log filename
pub wspr_file: String,
/// WEFAX decoder log filename
pub wefax_file: String,
}
impl Default for DecodeLogsConfig {
@@ -62,6 +64,7 @@ impl Default for DecodeLogsConfig {
cw_file: "TRXRS-CW-%YYYY%-%MM%-%DD%.log".to_string(),
ft8_file: "TRXRS-FT8-%YYYY%-%MM%-%DD%.log".to_string(),
wspr_file: "TRXRS-WSPR-%YYYY%-%MM%-%DD%.log".to_string(),
wefax_file: "TRXRS-WEFAX-%YYYY%-%MM%-%DD%.log".to_string(),
}
}
}
@@ -176,6 +179,7 @@ pub struct DecoderLoggers {
cw: DecoderFileLogger,
ft8: DecoderFileLogger,
wspr: DecoderFileLogger,
wefax: DecoderFileLogger,
}
impl DecoderLoggers {
@@ -194,6 +198,7 @@ impl DecoderLoggers {
cw: DecoderFileLogger::open(&base_dir, &cfg.cw_file, "cw")?,
ft8: DecoderFileLogger::open(&base_dir, &cfg.ft8_file, "ft8")?,
wspr: DecoderFileLogger::open(&base_dir, &cfg.wspr_file, "wspr")?,
wefax: DecoderFileLogger::open(&base_dir, &cfg.wefax_file, "wefax")?,
};
Ok(Some(Arc::new(loggers)))
@@ -214,4 +219,8 @@ impl DecoderLoggers {
pub fn log_wspr(&self, msg: &WsprMessage) {
self.wspr.write_payload(msg);
}
pub fn log_wefax(&self, msg: &WefaxMessage) {
self.wefax.write_payload(msg);
}
}
+12
View File
@@ -0,0 +1,12 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-wefax"
version.workspace = true
edition = "2021"
[dependencies]
trx-core = { path = "../../trx-core" }
png = "0.17"
+52
View File
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! WEFAX decoder configuration.
/// Configuration for the WEFAX decoder.
#[derive(Debug, Clone)]
pub struct WefaxConfig {
/// Lines per minute: 60, 90, 120, 240. `None` = auto-detect from APT.
pub lpm: Option<u16>,
/// Index of Cooperation: 288 or 576. `None` = auto-detect from start tone.
pub ioc: Option<u16>,
/// Centre frequency of the FM subcarrier (default 1900 Hz).
pub center_freq_hz: f32,
/// Deviation (default ±400 Hz, so black=1500, white=2300).
pub deviation_hz: f32,
/// Directory for saving decoded images.
pub output_dir: Option<String>,
/// Whether to emit line-by-line progress events.
pub emit_progress: bool,
}
impl Default for WefaxConfig {
fn default() -> Self {
Self {
lpm: None,
ioc: None,
center_freq_hz: 1900.0,
deviation_hz: 400.0,
output_dir: None,
emit_progress: true,
}
}
}
impl WefaxConfig {
/// Pixels per line for a given IOC value: `IOC × π`, rounded.
pub fn pixels_per_line(ioc: u16) -> u16 {
(f64::from(ioc) * std::f64::consts::PI).round() as u16
}
/// Line duration in seconds for a given LPM value.
pub fn line_duration_s(lpm: u16) -> f32 {
60.0 / lpm as f32
}
/// Samples per line at the internal sample rate.
pub fn samples_per_line(lpm: u16, sample_rate: u32) -> usize {
(Self::line_duration_s(lpm) * sample_rate as f32).round() as usize
}
}
+345
View File
@@ -0,0 +1,345 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Top-level WEFAX decoder state machine.
//!
//! Drives the DSP pipeline: resampler → FM discriminator → tone detector →
//! phasing → line slicer → image assembler.
use std::path::PathBuf;
use trx_core::decode::{WefaxMessage, WefaxProgress};
use crate::config::WefaxConfig;
use crate::demod::FmDiscriminator;
use crate::image::ImageAssembler;
use crate::line_slicer::LineSlicer;
use crate::phase::PhasingDetector;
use crate::resampler::{Resampler, INTERNAL_RATE};
use crate::tone_detect::{AptTone, ToneDetector};
/// Progress events are emitted every this many lines.
const PROGRESS_INTERVAL: u32 = 5;
/// WEFAX decoder output event.
#[derive(Debug)]
pub enum WefaxEvent {
/// A progress update with line data for live rendering.
Progress(WefaxProgress, Vec<u8>),
/// A completed image.
Complete(WefaxMessage),
}
/// Internal decoder state.
#[derive(Debug, Clone, PartialEq, Eq)]
enum State {
/// Listening for APT start tone.
Idle,
/// Start tone detected; waiting for phasing signal.
StartDetected { ioc: u16 },
/// Receiving phasing lines; aligning line-start phase.
Phasing { ioc: u16, lpm: u16 },
/// Actively decoding image lines.
Receiving { ioc: u16, lpm: u16 },
/// Stop tone detected; finalising image.
Stopping { ioc: u16, lpm: u16 },
}
/// Top-level WEFAX decoder.
pub struct WefaxDecoder {
config: WefaxConfig,
state: State,
resampler: Resampler,
demodulator: FmDiscriminator,
tone_detector: ToneDetector,
phasing: Option<PhasingDetector>,
slicer: Option<LineSlicer>,
image: Option<ImageAssembler>,
/// Total sample counter for timestamps.
sample_count: u64,
/// Timestamp (ms since epoch) when reception started.
reception_start_ms: Option<i64>,
}
impl WefaxDecoder {
pub fn new(input_sample_rate: u32, config: WefaxConfig) -> Self {
Self {
resampler: Resampler::new(input_sample_rate),
demodulator: FmDiscriminator::new(
INTERNAL_RATE,
config.center_freq_hz,
config.deviation_hz,
),
tone_detector: ToneDetector::new(INTERNAL_RATE),
config,
state: State::Idle,
phasing: None,
slicer: None,
image: None,
sample_count: 0,
reception_start_ms: None,
}
}
/// Process a block of PCM audio samples (mono, at the input sample rate).
///
/// Returns any events generated during processing.
pub fn process_samples(&mut self, samples: &[f32]) -> Vec<WefaxEvent> {
self.sample_count += samples.len() as u64;
let mut events = Vec::new();
// Step 1: Resample to internal rate.
let resampled = self.resampler.process(samples);
// Step 2: Always run tone detector on raw resampled audio.
let tone_results = self.tone_detector.process(&resampled);
// Step 3: FM demodulate to get luminance values.
let luminance = self.demodulator.process(&resampled);
// Step 4: Process based on current state.
match self.state.clone() {
State::Idle => {
// Look for start tone.
for result in &tone_results {
if let Some(tone) = result.tone {
match tone {
AptTone::Start576 => {
self.transition_to_start_detected(576);
break;
}
AptTone::Start288 => {
self.transition_to_start_detected(288);
break;
}
AptTone::Stop => {} // Ignore stop in idle.
}
}
}
}
State::StartDetected { ioc } => {
// Wait for tone to end (no more start tone detected), then
// transition to phasing.
let still_start = tone_results
.iter()
.any(|r| matches!(r.tone, Some(AptTone::Start576 | AptTone::Start288)));
if !still_start {
self.transition_to_phasing(ioc);
}
}
State::Phasing { ioc, lpm } => {
// Check for stop tone (abort).
if tone_results
.iter()
.any(|r| r.tone == Some(AptTone::Stop))
{
self.transition_to_idle();
return events;
}
if let Some(ref mut phasing) = self.phasing {
if let Some(offset) = phasing.process(&luminance) {
self.transition_to_receiving(ioc, lpm, offset);
}
}
}
State::Receiving { ioc, lpm } => {
// Check for stop tone.
if tone_results
.iter()
.any(|r| r.tone == Some(AptTone::Stop))
{
self.state = State::Stopping { ioc, lpm };
events.extend(self.finalize_image(ioc, lpm));
self.transition_to_idle();
return events;
}
// Feed luminance to line slicer.
if let Some(ref mut slicer) = self.slicer {
let new_lines = slicer.process(&luminance);
for line in new_lines {
if let Some(ref mut image) = self.image {
image.push_line(line);
let count = image.line_count();
// Emit progress event.
if self.config.emit_progress && count % PROGRESS_INTERVAL == 0 {
let line_data = image
.last_line()
.map(|l| l.to_vec())
.unwrap_or_default();
events.push(WefaxEvent::Progress(
WefaxProgress {
rig_id: None,
line_count: count,
lpm,
ioc,
pixels_per_line: WefaxConfig::pixels_per_line(ioc),
line_data: Some(line_data.clone()),
},
line_data,
));
}
}
}
}
}
State::Stopping { .. } => {
// Already handled, transition back to idle.
self.transition_to_idle();
}
}
events
}
/// Reset the decoder, discarding any in-progress image.
pub fn reset(&mut self) {
self.state = State::Idle;
self.resampler.reset();
self.demodulator.reset();
self.tone_detector.reset();
self.phasing = None;
self.slicer = None;
self.image = None;
self.sample_count = 0;
self.reception_start_ms = None;
}
/// Check if the decoder is currently receiving an image.
pub fn is_receiving(&self) -> bool {
matches!(
self.state,
State::Phasing { .. } | State::Receiving { .. }
)
}
fn transition_to_start_detected(&mut self, ioc: u16) {
let ioc = self.config.ioc.unwrap_or(ioc);
self.state = State::StartDetected { ioc };
self.reception_start_ms = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64,
);
}
fn transition_to_phasing(&mut self, ioc: u16) {
let lpm = self.config.lpm.unwrap_or(120); // Default 120 LPM.
self.tone_detector.reset();
self.phasing = Some(PhasingDetector::new(lpm, INTERNAL_RATE));
self.demodulator.reset();
self.state = State::Phasing { ioc, lpm };
}
fn transition_to_receiving(&mut self, ioc: u16, lpm: u16, phase_offset: usize) {
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));
self.tone_detector.reset();
self.state = State::Receiving { ioc, lpm };
}
fn transition_to_idle(&mut self) {
self.state = State::Idle;
self.phasing = None;
self.slicer = None;
// image is kept until finalize_image is called or next reception starts.
self.tone_detector.reset();
}
fn finalize_image(&mut self, ioc: u16, lpm: u16) -> Vec<WefaxEvent> {
let mut events = Vec::new();
if let Some(ref image) = self.image {
if image.line_count() == 0 {
return events;
}
let ppl = WefaxConfig::pixels_per_line(ioc);
let mut path_str = None;
// Save PNG if output directory is configured.
if let Some(ref dir) = self.config.output_dir {
let output_path = PathBuf::from(dir);
match image.save_png(&output_path, ioc, lpm) {
Ok(p) => {
path_str = Some(p.to_string_lossy().into_owned());
}
Err(e) => {
// Log the error but still emit the completion event.
eprintln!("WEFAX: failed to save PNG: {}", e);
}
}
}
events.push(WefaxEvent::Complete(WefaxMessage {
rig_id: None,
ts_ms: self.reception_start_ms,
line_count: image.line_count(),
lpm,
ioc,
pixels_per_line: ppl,
path: path_str,
complete: true,
}));
}
self.image = None;
self.reception_start_ms = None;
events
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
fn generate_tone(freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> {
let n = (sample_rate as f32 * duration_s) as usize;
(0..n)
.map(|i| (2.0 * PI * freq * i as f32 / sample_rate as f32).sin())
.collect()
}
#[test]
fn decoder_starts_idle() {
let dec = WefaxDecoder::new(48000, WefaxConfig::default());
assert_eq!(dec.state, State::Idle);
assert!(!dec.is_receiving());
}
#[test]
fn decoder_detects_start_tone() {
let mut dec = WefaxDecoder::new(11025, WefaxConfig::default());
// Feed 3 seconds of 300 Hz start tone directly at internal rate.
// (bypass resampler by using internal rate as input rate)
let tone = generate_tone(300.0, 11025, 3.0);
dec.process_samples(&tone);
assert!(
matches!(dec.state, State::StartDetected { ioc: 576 } | State::Phasing { ioc: 576, .. }),
"state should be StartDetected or Phasing, got {:?}",
dec.state
);
}
#[test]
fn decoder_reset_returns_to_idle() {
let mut dec = WefaxDecoder::new(48000, WefaxConfig::default());
dec.state = State::Receiving {
ioc: 576,
lpm: 120,
};
dec.reset();
assert_eq!(dec.state, State::Idle);
}
}
+195
View File
@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! FM discriminator for WEFAX demodulation.
//!
//! Computes instantaneous frequency from the analytic signal produced by a
//! Hilbert transform FIR, then maps the frequency to a 0.01.0 luminance
//! value (1500 Hz = black, 2300 Hz = white).
use std::f32::consts::PI;
/// Number of taps for the Hilbert transform FIR.
const HILBERT_TAPS: usize = 65;
/// Half the Hilbert FIR length (group delay in samples).
const HILBERT_DELAY: usize = HILBERT_TAPS / 2;
/// FM discriminator producing luminance values from audio samples.
pub struct FmDiscriminator {
sample_rate: f32,
/// Hilbert FIR coefficients (odd-length, anti-symmetric).
hilbert_coeffs: Vec<f32>,
/// Input sample delay line for FIR convolution.
delay_line: Vec<f32>,
/// Write position in delay line (circular buffer).
write_pos: usize,
/// Previous analytic signal sample for frequency differentiation.
prev_i: f32,
prev_q: f32,
/// Centre frequency for luminance mapping.
center_hz: f32,
/// Deviation for luminance mapping.
deviation_hz: f32,
}
impl FmDiscriminator {
pub fn new(sample_rate: u32, center_hz: f32, deviation_hz: f32) -> Self {
let coeffs = design_hilbert_fir(HILBERT_TAPS);
Self {
sample_rate: sample_rate as f32,
hilbert_coeffs: coeffs,
delay_line: vec![0.0; HILBERT_TAPS],
write_pos: 0,
prev_i: 0.0,
prev_q: 0.0,
center_hz,
deviation_hz,
}
}
/// Process a block of real-valued audio samples, returning luminance
/// values in the range 0.0 (black / 1500 Hz) to 1.0 (white / 2300 Hz).
pub fn process(&mut self, samples: &[f32]) -> Vec<f32> {
let mut output = Vec::with_capacity(samples.len());
let n = HILBERT_TAPS;
let half = HILBERT_DELAY;
let inv_2pi_ts = self.sample_rate / (2.0 * PI);
let black_hz = self.center_hz - self.deviation_hz; // 1500
let range_hz = 2.0 * self.deviation_hz; // 800
for &sample in samples {
// Write into circular delay line.
self.delay_line[self.write_pos] = sample;
self.write_pos = (self.write_pos + 1) % n;
// Compute Hilbert-transformed (quadrature) output via FIR.
let mut q = 0.0f32;
for k in 0..n {
let idx = (self.write_pos + k) % n;
q += self.hilbert_coeffs[k] * self.delay_line[idx];
}
// The in-phase component is the delayed input (centre tap of the
// Hilbert FIR corresponds to the group delay).
let i = self.delay_line[(self.write_pos + half) % n];
// Instantaneous frequency via phase differentiation:
// f = arg(z[n] · conj(z[n-1])) / (2π·Ts)
// z[n] · conj(z[n-1]) = (i + jq)(prev_i - j·prev_q)
let di = i * self.prev_i + q * self.prev_q;
let dq = q * self.prev_i - i * self.prev_q;
let phase_diff = dq.atan2(di);
let freq = phase_diff.abs() * inv_2pi_ts;
// Map frequency to luminance.
let lum = ((freq - black_hz) / range_hz).clamp(0.0, 1.0);
output.push(lum);
self.prev_i = i;
self.prev_q = q;
}
output
}
pub fn reset(&mut self) {
self.delay_line.fill(0.0);
self.write_pos = 0;
self.prev_i = 0.0;
self.prev_q = 0.0;
}
}
/// Design a Hilbert transform FIR filter (odd-length, type III).
///
/// The impulse response is: h[n] = 2/(πn) for odd n (relative to centre),
/// 0 for even n, windowed with a Blackman window.
#[allow(clippy::needless_range_loop)]
fn design_hilbert_fir(num_taps: usize) -> Vec<f32> {
assert!(num_taps % 2 == 1, "Hilbert FIR must have odd length");
let mut coeffs = vec![0.0f32; num_taps];
let mid = (num_taps - 1) as f64 / 2.0;
for i in 0..num_taps {
let n = i as f64 - mid;
let ni = n.round() as i64;
if ni == 0 {
coeffs[i] = 0.0;
} else if ni % 2 != 0 {
// Hilbert kernel: 2/(π·n) for odd offsets.
let h = 2.0 / (std::f64::consts::PI * n);
// Blackman window.
let w = 0.42
- 0.5 * (2.0 * std::f64::consts::PI * i as f64 / (num_taps - 1) as f64).cos()
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / (num_taps - 1) as f64).cos();
coeffs[i] = (h * w) as f32;
}
}
coeffs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discriminator_white_tone() {
// Feed a pure 2300 Hz tone, expect luminance ≈ 1.0.
let sr = 11025;
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
let n = 2000;
let tone: Vec<f32> = (0..n)
.map(|i| (2.0 * PI * 2300.0 * i as f32 / sr as f32).sin())
.collect();
let lum = disc.process(&tone);
// Skip initial transient (Hilbert FIR settling).
let tail = &lum[lum.len() / 2..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!(
(avg - 1.0).abs() < 0.05,
"expected ~1.0 for white tone, got {}",
avg
);
}
#[test]
fn discriminator_black_tone() {
// Feed a pure 1500 Hz tone, expect luminance ≈ 0.0.
let sr = 11025;
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
let n = 2000;
let tone: Vec<f32> = (0..n)
.map(|i| (2.0 * PI * 1500.0 * i as f32 / sr as f32).sin())
.collect();
let lum = disc.process(&tone);
let tail = &lum[lum.len() / 2..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!(
avg < 0.05,
"expected ~0.0 for black tone, got {}",
avg
);
}
#[test]
fn discriminator_center_tone() {
// Feed 1900 Hz (center), expect luminance ≈ 0.5.
let sr = 11025;
let mut disc = FmDiscriminator::new(sr, 1900.0, 400.0);
let n = 2000;
let tone: Vec<f32> = (0..n)
.map(|i| (2.0 * PI * 1900.0 * i as f32 / sr as f32).sin())
.collect();
let lum = disc.process(&tone);
let tail = &lum[lum.len() / 2..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!(
(avg - 0.5).abs() < 0.05,
"expected ~0.5 for center tone, got {}",
avg
);
}
}
+208
View File
@@ -0,0 +1,208 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Image buffer and PNG encoding for WEFAX decoded images.
use std::io::BufWriter;
use std::path::{Path, PathBuf};
/// Image assembler: accumulates greyscale lines and encodes to PNG.
pub struct ImageAssembler {
pixels_per_line: usize,
lines: Vec<Vec<u8>>,
}
impl ImageAssembler {
pub fn new(pixels_per_line: usize) -> Self {
Self {
pixels_per_line,
lines: Vec::with_capacity(800),
}
}
/// Append a completed greyscale line.
pub fn push_line(&mut self, line: Vec<u8>) {
debug_assert_eq!(line.len(), self.pixels_per_line);
self.lines.push(line);
}
/// Number of lines accumulated so far.
pub fn line_count(&self) -> u32 {
self.lines.len() as u32
}
/// Get the most recently added line (for progress events).
pub fn last_line(&self) -> Option<&[u8]> {
self.lines.last().map(|l| l.as_slice())
}
/// Encode the accumulated image to an 8-bit greyscale PNG file.
///
/// Returns the full path to the saved file.
pub fn save_png(
&self,
output_dir: &Path,
ioc: u16,
lpm: u16,
) -> Result<PathBuf, String> {
if self.lines.is_empty() {
return Err("no image lines to save".into());
}
std::fs::create_dir_all(output_dir)
.map_err(|e| format!("create output dir: {}", e))?;
let filename = generate_filename(ioc, lpm);
let path = output_dir.join(&filename);
let file = std::fs::File::create(&path)
.map_err(|e| format!("create PNG file '{}': {}", path.display(), e))?;
let w = BufWriter::new(file);
let width = self.pixels_per_line as u32;
let height = self.lines.len() as u32;
let mut encoder = png::Encoder::new(w, width, height);
encoder.set_color(png::ColorType::Grayscale);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| format!("write PNG header: {}", e))?;
// Write all rows.
let mut img_data = Vec::with_capacity((width * height) as usize);
for line in &self.lines {
img_data.extend_from_slice(line);
}
writer
.write_image_data(&img_data)
.map_err(|e| format!("write PNG data: {}", e))?;
Ok(path)
}
pub fn reset(&mut self) {
self.lines.clear();
}
}
fn generate_filename(ioc: u16, lpm: u16) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
// Convert to UTC datetime components manually (avoid chrono dependency).
let (year, month, day, hour, min, sec) = unix_to_utc(secs);
format!(
"WEFAX-{:04}-{:02}-{:02}T{:02}{:02}{:02}-IOC{}-{}lpm.png",
year, month, day, hour, min, sec, ioc, lpm
)
}
/// Convert Unix timestamp to (year, month, day, hour, minute, second) in UTC.
fn unix_to_utc(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
let s = secs;
let sec = (s % 60) as u32;
let min = ((s / 60) % 60) as u32;
let hour = ((s / 3600) % 24) as u32;
let mut days = (s / 86400) as i64;
// Days since 1970-01-01.
let mut year = 1970u32;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
if days < days_in_year {
break;
}
days -= days_in_year;
year += 1;
}
let leap = is_leap(year);
let month_days = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 0u32;
for (i, &md) in month_days.iter().enumerate() {
if days < md as i64 {
month = i as u32 + 1;
break;
}
days -= md as i64;
}
let day = days as u32 + 1;
(year, month, day, hour, min, sec)
}
fn is_leap(y: u32) -> bool {
y.is_multiple_of(4) && (!y.is_multiple_of(100) || y.is_multiple_of(400))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_assembler_line_count() {
let mut asm = ImageAssembler::new(1809);
assert_eq!(asm.line_count(), 0);
asm.push_line(vec![128; 1809]);
assert_eq!(asm.line_count(), 1);
asm.push_line(vec![255; 1809]);
assert_eq!(asm.line_count(), 2);
}
#[test]
fn save_png_to_temp_dir() {
let mut asm = ImageAssembler::new(100);
for i in 0..50 {
let val = (i * 255 / 49) as u8;
asm.push_line(vec![val; 100]);
}
let dir = std::env::temp_dir().join("trx-wefax-test");
let result = asm.save_png(&dir, 576, 120);
assert!(result.is_ok(), "save_png failed: {:?}", result.err());
let path = result.unwrap();
assert!(path.exists());
// Clean up.
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn unix_to_utc_epoch() {
let (y, m, d, h, mi, s) = unix_to_utc(0);
assert_eq!((y, m, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
}
#[test]
fn unix_to_utc_known_date() {
// 2026-03-28T14:30:00 UTC = 1774718600 (approximately)
let (y, m, d, h, mi, _) = unix_to_utc(1775055000);
assert_eq!(y, 2026);
// Just verify reasonable values without asserting exact date.
assert!(m >= 1 && m <= 12);
assert!(d >= 1 && d <= 31);
assert!(h < 24);
assert!(mi < 60);
}
}
+20
View File
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! WEFAX (Weather Facsimile) decoder.
//!
//! Pure Rust implementation supporting 60/90/120/240 LPM, IOC 288 and 576,
//! with automatic APT tone detection and phase alignment.
pub mod config;
pub mod decoder;
pub mod demod;
pub mod image;
pub mod line_slicer;
pub mod phase;
pub mod resampler;
pub mod tone_detect;
pub use config::WefaxConfig;
pub use decoder::{WefaxDecoder, WefaxEvent};
+149
View File
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Line slicer: pixel clock recovery and line buffer assembly.
//!
//! Once the phasing detector has established a line-start phase offset,
//! the line slicer accumulates demodulated luminance samples and extracts
//! complete image lines at the configured LPM rate.
use crate::config::WefaxConfig;
/// Line slicer for WEFAX image assembly.
pub struct LineSlicer {
/// Samples per line at the internal sample rate.
samples_per_line: usize,
/// Pixels per line (IOC × π).
pixels_per_line: usize,
/// Phase offset in samples from the phasing detector.
phase_offset: usize,
/// Accumulated luminance samples.
buffer: Vec<f32>,
/// Number of samples consumed since the last phase alignment point.
consumed: usize,
/// Whether we have aligned to the phase offset yet.
aligned: bool,
}
impl LineSlicer {
pub fn new(lpm: u16, ioc: u16, sample_rate: u32, phase_offset: usize) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let pixels_per_line = WefaxConfig::pixels_per_line(ioc) as usize;
Self {
samples_per_line,
pixels_per_line,
phase_offset,
buffer: Vec::with_capacity(samples_per_line * 2),
consumed: 0,
aligned: false,
}
}
/// Feed luminance samples and extract complete image lines.
///
/// Returns a vector of completed lines, each as a `Vec<u8>` of
/// greyscale pixel values (0255).
pub fn process(&mut self, lum_samples: &[f32]) -> Vec<Vec<u8>> {
self.buffer.extend_from_slice(lum_samples);
let mut lines = Vec::new();
// On first call, skip samples to align to the phase offset.
if !self.aligned {
if self.buffer.len() < self.phase_offset {
return lines;
}
self.buffer.drain(..self.phase_offset);
self.aligned = true;
}
// Extract complete lines.
while self.buffer.len() >= self.samples_per_line {
let line_samples = &self.buffer[..self.samples_per_line];
let pixels = self.resample_line(line_samples);
lines.push(pixels);
self.buffer.drain(..self.samples_per_line);
self.consumed += self.samples_per_line;
}
lines
}
pub fn pixels_per_line(&self) -> usize {
self.pixels_per_line
}
pub fn reset(&mut self) {
self.buffer.clear();
self.consumed = 0;
self.aligned = false;
}
/// Resample a line's worth of luminance samples to the target pixel count
/// using linear interpolation.
fn resample_line(&self, samples: &[f32]) -> Vec<u8> {
let n_samples = samples.len() as f32;
let n_pixels = self.pixels_per_line;
let mut pixels = Vec::with_capacity(n_pixels);
for px in 0..n_pixels {
// Map pixel index to sample position.
let pos = (px as f32 + 0.5) * n_samples / n_pixels as f32;
let idx = pos.floor() as usize;
let frac = pos - idx as f32;
let v = if idx + 1 < samples.len() {
samples[idx] * (1.0 - frac) + samples[idx + 1] * frac
} else if idx < samples.len() {
samples[idx]
} else {
0.0
};
pixels.push((v * 255.0).clamp(0.0, 255.0) as u8);
}
pixels
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slicer_extracts_correct_line_count() {
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line(lpm, sr);
let ppl = WefaxConfig::pixels_per_line(ioc) as usize;
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
// Feed exactly 3 lines worth of white.
let samples = vec![1.0f32; spl * 3];
let lines = slicer.process(&samples);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].len(), ppl);
// All pixels should be white (255).
assert!(lines[0].iter().all(|&p| p == 255));
}
#[test]
fn slicer_linear_interpolation() {
let lpm = 120;
let ioc = 576;
let sr = 11025;
let spl = WefaxConfig::samples_per_line(lpm, sr);
let mut slicer = LineSlicer::new(lpm, ioc, sr, 0);
// Feed a linear ramp from 0.0 to 1.0.
let samples: Vec<f32> = (0..spl).map(|i| i as f32 / spl as f32).collect();
let lines = slicer.process(&samples);
assert_eq!(lines.len(), 1);
// First pixel should be near 0, last pixel near 255.
assert!(lines[0][0] < 5);
assert!(lines[0].last().copied().unwrap_or(0) > 250);
}
}
+189
View File
@@ -0,0 +1,189 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Phasing signal detector and line-start alignment for WEFAX.
//!
//! During the phasing period, each line is >95% white (luminance ≈ 1.0) with
//! a narrow black pulse (~5% of line width) marking the line-start position.
//! This module detects the pulse position via cross-correlation against
//! a synthetic phasing template, and averages over multiple lines to
//! establish a stable phase offset.
use crate::config::WefaxConfig;
/// Minimum number of phasing lines needed to establish phase lock.
const MIN_PHASING_LINES: usize = 10;
/// Maximum variance (in samples²) of pulse position for phase to be considered stable.
const MAX_PHASE_VARIANCE: f32 = 16.0;
/// Fraction of line width occupied by the black pulse in phasing signal.
const PULSE_WIDTH_FRACTION: f32 = 0.05;
/// Phasing signal detector.
pub struct PhasingDetector {
samples_per_line: usize,
pulse_width: usize,
/// Collected pulse positions from each phasing line.
pub(crate) pulse_positions: Vec<usize>,
/// Luminance sample accumulator for the current line.
line_buffer: Vec<f32>,
/// Established phase offset (samples from buffer start to line start).
phase_offset: Option<usize>,
}
impl PhasingDetector {
pub fn new(lpm: u16, sample_rate: u32) -> Self {
let samples_per_line = WefaxConfig::samples_per_line(lpm, sample_rate);
let pulse_width = (samples_per_line as f32 * PULSE_WIDTH_FRACTION).round() as usize;
Self {
samples_per_line,
pulse_width,
pulse_positions: Vec::new(),
line_buffer: Vec::with_capacity(samples_per_line),
phase_offset: None,
}
}
/// Feed luminance samples. Returns `Some(offset)` once phase is locked.
pub fn process(&mut self, lum_samples: &[f32]) -> Option<usize> {
if self.phase_offset.is_some() {
return self.phase_offset;
}
for &s in lum_samples {
self.line_buffer.push(s);
if self.line_buffer.len() >= self.samples_per_line {
self.analyze_phasing_line();
self.line_buffer.clear();
}
}
self.phase_offset
}
/// Return the established phase offset, if locked.
pub fn offset(&self) -> Option<usize> {
self.phase_offset
}
/// Check if phasing is complete and offset is stable.
pub fn is_locked(&self) -> bool {
self.phase_offset.is_some()
}
pub fn reset(&mut self) {
self.pulse_positions.clear();
self.line_buffer.clear();
self.phase_offset = None;
}
fn analyze_phasing_line(&mut self) {
let line = &self.line_buffer;
// Verify this looks like a phasing line: >90% should be high luminance.
let white_count = line.iter().filter(|&&v| v > 0.7).count();
if white_count < line.len() * 85 / 100 {
// Not a phasing line; reset accumulated positions.
self.pulse_positions.clear();
return;
}
// Find the black pulse position via minimum-energy sliding window.
let pw = self.pulse_width.max(1);
let mut min_energy = f32::MAX;
let mut min_pos = 0;
// Running sum for efficiency.
let mut sum: f32 = line[..pw].iter().sum();
if sum < min_energy {
min_energy = sum;
min_pos = 0;
}
for i in 1..=(line.len() - pw) {
sum += line[i + pw - 1] - line[i - 1];
if sum < min_energy {
min_energy = sum;
min_pos = i;
}
}
// The black pulse should be significantly darker than the average.
let avg_pulse = min_energy / pw as f32;
if avg_pulse > 0.3 {
// Pulse not dark enough, skip this line.
return;
}
// Record pulse position (centre of the pulse window).
self.pulse_positions.push(min_pos + pw / 2);
// Check if we have enough samples and the variance is low.
if self.pulse_positions.len() >= MIN_PHASING_LINES {
let mean = self.pulse_positions.iter().sum::<usize>() as f32
/ self.pulse_positions.len() as f32;
let variance = self
.pulse_positions
.iter()
.map(|&p| {
let d = p as f32 - mean;
d * d
})
.sum::<f32>()
/ self.pulse_positions.len() as f32;
if variance < MAX_PHASE_VARIANCE {
self.phase_offset = Some(mean.round() as usize);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_phasing_pulse() {
let lpm = 120;
let sr = 11025;
let spl = WefaxConfig::samples_per_line(lpm, sr);
let mut det = PhasingDetector::new(lpm, sr);
// Create 20 phasing lines with a black pulse at ~10% of line width.
let pw = (spl as f32 * PULSE_WIDTH_FRACTION).round() as usize;
let pulse_start = spl / 10;
let pulse_center = pulse_start + pw / 2;
for line_idx in 0..20 {
let mut line = vec![1.0f32; spl];
for j in pulse_start..pulse_start + pw {
if j < spl {
line[j] = 0.0;
}
}
let result = det.process(&line);
if let Some(offset) = result {
assert!(
(offset as i32 - pulse_center as i32).unsigned_abs() <= 3,
"phase offset {} too far from expected {} (line {})",
offset,
pulse_center,
line_idx,
);
return;
}
}
panic!(
"phasing should have locked after 20 lines (spl={}, pw={}, positions={:?})",
spl,
pw,
det.pulse_positions
);
}
}
+199
View File
@@ -0,0 +1,199 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Polyphase rational resampler: 48000 Hz → 11025 Hz.
//!
//! Ratio: 11025/48000 = 441/1920 (after GCD reduction).
//! Uses a polyphase FIR filter bank to avoid computing the full upsampled
//! signal, consistent with `docs/Optimization-Guidelines.md`.
/// Internal processing sample rate.
pub const INTERNAL_RATE: u32 = 11025;
/// Default input sample rate.
pub const DEFAULT_INPUT_RATE: u32 = 48000;
/// Polyphase rational resampler.
pub struct Resampler {
/// Interpolation factor (numerator of the ratio).
up: usize,
/// Decimation factor (denominator of the ratio).
down: usize,
/// Number of taps per polyphase sub-filter.
taps_per_phase: usize,
/// Polyphase filter bank: `up` sub-filters, each with `taps_per_phase` taps.
bank: Vec<Vec<f32>>,
/// Input history buffer for FIR convolution.
history: Vec<f32>,
/// Current phase accumulator (tracks position in the up-sampled domain).
phase: usize,
}
impl Resampler {
/// Create a resampler from `input_rate` to [`INTERNAL_RATE`].
pub fn new(input_rate: u32) -> Self {
let g = gcd(INTERNAL_RATE as usize, input_rate as usize);
let up = INTERNAL_RATE as usize / g;
let down = input_rate as usize / g;
// Design a low-pass FIR prototype for the upsampled rate.
// The upsampled rate is `input_rate * up`. The output is then
// decimated by `down`. The anti-alias cutoff should be at
// `min(input_rate, output_rate) / 2`, which in normalized terms
// (relative to the upsampled rate) is `0.5 / max(up, down)`.
// Use 0.45 instead of 0.5 for transition band headroom.
let num_taps = up * 16 + 1; // ~16 taps per phase
let cutoff = 0.5 / (up.max(down) as f64);
let prototype = design_lowpass(num_taps, cutoff, up as f64);
// Split prototype into polyphase bank.
let taps_per_phase = prototype.len().div_ceil(up);
let mut bank = vec![vec![0.0f32; taps_per_phase]; up];
for (i, &coeff) in prototype.iter().enumerate() {
let phase = i % up;
let tap = i / up;
bank[phase][tap] = coeff;
}
// Normalize: each output sample comes from one sub-filter convolved
// with the input history. For unity DC gain, each sub-filter's sum
// must equal 1.0.
for sub in &mut bank {
let sub_sum: f64 = sub.iter().map(|&c| c as f64).sum();
if sub_sum.abs() > 1e-12 {
let scale = (1.0 / sub_sum) as f32;
for c in sub.iter_mut() {
*c *= scale;
}
}
}
let history = vec![0.0f32; taps_per_phase];
Self {
up,
down,
taps_per_phase,
bank,
history,
phase: 0,
}
}
/// Process a block of input samples, returning resampled output.
#[allow(clippy::needless_range_loop)]
pub fn process(&mut self, input: &[f32]) -> Vec<f32> {
let mut output = Vec::with_capacity(input.len() * self.up / self.down + 2);
for &sample in input {
// Shift sample into history (newest at end).
self.history.copy_within(1.., 0);
self.history[self.taps_per_phase - 1] = sample;
// Generate output samples for all phases that map to this input.
while self.phase < self.up {
let coeffs = &self.bank[self.phase];
let mut acc = 0.0f32;
for k in 0..self.taps_per_phase {
// History is stored newest-last, coefficients are indexed
// from newest to oldest (matching the polyphase decomposition).
acc += coeffs[k] * self.history[self.taps_per_phase - 1 - k];
}
output.push(acc);
self.phase += self.down;
}
self.phase -= self.up;
}
output
}
/// Reset internal state (call on frequency change / decoder reset).
pub fn reset(&mut self) {
self.history.fill(0.0);
self.phase = 0;
}
}
/// Design a windowed-sinc low-pass FIR filter.
#[allow(clippy::needless_range_loop)]
fn design_lowpass(num_taps: usize, cutoff: f64, gain: f64) -> Vec<f32> {
let mut coeffs = vec![0.0f32; num_taps];
let m = num_taps as f64 - 1.0;
let mid = m / 2.0;
for i in 0..num_taps {
let n = i as f64 - mid;
// Sinc function.
let sinc = if n.abs() < 1e-12 {
2.0 * std::f64::consts::PI * cutoff
} else {
(2.0 * std::f64::consts::PI * cutoff * n).sin() / n
};
// Blackman window.
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
coeffs[i] = (sinc * w * gain) as f32;
}
coeffs
}
fn gcd(mut a: usize, mut b: usize) -> usize {
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resampler_ratio_48k_to_11025() {
let r = Resampler::new(48000);
// Feed 48000 samples, should get ~11025 out.
let input: Vec<f32> = vec![0.0; 48000];
let output = r.clone_and_process(&input);
// Allow ±2 samples tolerance for edge effects.
assert!(
(output.len() as i64 - 11025).unsigned_abs() <= 2,
"expected ~11025 samples, got {}",
output.len()
);
}
#[test]
fn resampler_dc_passthrough() {
let mut r = Resampler::new(48000);
// DC signal should pass through with unity gain (after settling).
let input: Vec<f32> = vec![1.0; 4800];
let output = r.process(&input);
// Check last quarter of output is close to 1.0.
let tail = &output[output.len() * 3 / 4..];
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
assert!(
(avg - 1.0).abs() < 0.02,
"DC gain mismatch: avg = {}",
avg
);
}
impl Resampler {
fn clone_and_process(&self, input: &[f32]) -> Vec<f32> {
let mut r = Self {
up: self.up,
down: self.down,
taps_per_phase: self.taps_per_phase,
bank: self.bank.clone(),
history: self.history.clone(),
phase: self.phase,
};
r.process(input)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: BSD-2-Clause
//! Goertzel-based APT tone detector for WEFAX start/stop signals.
//!
//! Detects three tones:
//! - 300 Hz: Start tone for IOC 576
//! - 675 Hz: Start tone for IOC 288
//! - 450 Hz: Stop tone (end of transmission)
//!
//! Uses the same Goertzel pattern as `trx-cw`.
/// Detected APT tone type.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AptTone {
/// Start tone for IOC 576 (300 Hz).
Start576,
/// Start tone for IOC 288 (675 Hz).
Start288,
/// Stop tone (450 Hz).
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,
}
/// Goertzel tone detector for APT start/stop signals.
pub struct ToneDetector {
sample_rate: f32,
/// Goertzel analysis window size in samples (~200 ms).
window_size: usize,
/// Accumulated samples for the current window.
buffer: Vec<f32>,
/// Goertzel coefficients for each target frequency.
coeffs: [GoertzelCoeff; 3],
/// Currently sustained tone and duration counter.
current_tone: Option<AptTone>,
sustained_windows: u32,
/// Minimum sustained detection time in windows before confirming.
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 {
pub fn new(sample_rate: u32) -> Self {
let window_size = (sample_rate as f32 * 0.2) as usize; // ~200 ms
let min_sustain_s = 1.5;
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 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 {
sample_rate: sample_rate as f32,
window_size,
buffer: Vec::with_capacity(window_size),
coeffs,
current_tone: None,
sustained_windows: 0,
min_sustain_windows,
snr_threshold: 10.0, // tone must be 10× broadband energy
}
}
/// Feed audio samples (luminance values from FM discriminator are NOT
/// suitable; feed the raw resampled audio before demodulation).
pub fn process(&mut self, samples: &[f32]) -> Vec<ToneDetectResult> {
let mut results = Vec::new();
for &s in samples {
self.buffer.push(s);
if self.buffer.len() >= self.window_size {
results.push(self.analyze_window());
self.buffer.clear();
}
}
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.buffer.clear();
self.current_tone = None;
self.sustained_windows = 0;
}
fn analyze_window(&mut self) -> ToneDetectResult {
let samples = &self.buffer;
// Compute broadband energy (RMS²).
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.
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,
}
}
}
impl GoertzelCoeff {
fn new(tone: AptTone, freq: f32, sample_rate: u32, window_size: usize) -> Self {
let k = (freq * window_size as f32 / sample_rate as f32).round();
let coeff = 2.0 * (2.0 * std::f32::consts::PI * k / window_size as f32).cos();
Self { tone, coeff, freq }
}
}
/// 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)]
mod tests {
use super::*;
use std::f32::consts::PI;
fn generate_tone(freq: f32, sample_rate: u32, duration_s: f32) -> Vec<f32> {
let n = (sample_rate as f32 * duration_s) as usize;
(0..n)
.map(|i| (2.0 * PI * freq * i as f32 / sample_rate as f32).sin())
.collect()
}
#[test]
fn detect_start_576_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let tone = generate_tone(300.0, sr, 3.0); // 3 seconds of 300 Hz
let results = det.process(&tone);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start576));
assert!(confirmed, "should detect 300 Hz start tone for IOC 576");
}
#[test]
fn detect_start_288_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let tone = generate_tone(675.0, sr, 3.0);
let results = det.process(&tone);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Start288));
assert!(confirmed, "should detect 675 Hz start tone for IOC 288");
}
#[test]
fn detect_stop_tone() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let tone = generate_tone(450.0, sr, 3.0);
let results = det.process(&tone);
let confirmed = results.iter().any(|r| r.tone == Some(AptTone::Stop));
assert!(confirmed, "should detect 450 Hz stop tone");
}
#[test]
fn no_false_detect_on_silence() {
let sr = 11025;
let mut det = ToneDetector::new(sr);
let silence = vec![0.0f32; sr as usize * 3];
let results = det.process(&silence);
assert!(
results.iter().all(|r| r.tone.is_none()),
"should not detect any tone in silence"
);
}
}
+5 -2
View File
@@ -29,7 +29,8 @@ use trx_core::audio::{
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_RX_FRAME,
AUDIO_MSG_RX_FRAME_CH, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
AUDIO_MSG_LRPT_IMAGE, AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
AUDIO_MSG_LRPT_IMAGE, AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_WEFAX_DECODE,
AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB,
AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WSPR_DECODE,
};
use trx_core::decode::DecodedMessage;
@@ -569,7 +570,9 @@ async fn handle_single_rig_connection(
| AUDIO_MSG_FT2_DECODE
| AUDIO_MSG_WSPR_DECODE
| AUDIO_MSG_LRPT_IMAGE
| AUDIO_MSG_LRPT_PROGRESS,
| AUDIO_MSG_LRPT_PROGRESS
| AUDIO_MSG_WEFAX_DECODE
| AUDIO_MSG_WEFAX_PROGRESS,
payload,
)) => {
if let Ok(mut msg) = serde_json::from_slice::<DecodedMessage>(&payload) {
+4 -1
View File
@@ -17,7 +17,8 @@ use uuid::Uuid;
use trx_core::audio::AudioStreamInfo;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WsprMessage,
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
WsprMessage,
};
use trx_core::rig::state::{RigSnapshot, SpectrumData};
use trx_core::{DynResult, RigRequest, RigState};
@@ -230,6 +231,7 @@ pub struct DecodeHistoryContext {
pub ft4: DecodeHistory<Ft8Message>,
pub ft2: DecodeHistory<Ft8Message>,
pub wspr: DecodeHistory<WsprMessage>,
pub wefax: DecodeHistory<WefaxMessage>,
}
impl Default for DecodeHistoryContext {
@@ -244,6 +246,7 @@ impl Default for DecodeHistoryContext {
ft4: Arc::new(Mutex::new(VecDeque::new())),
ft2: Arc::new(Mutex::new(VecDeque::new())),
wspr: Arc::new(Mutex::new(VecDeque::new())),
wefax: Arc::new(Mutex::new(VecDeque::new())),
}
}
}
@@ -5926,7 +5926,9 @@ function dispatchDecodeMessage(msg, skipStats) {
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
if (msg.type === "lrpt_image" && window.onServerLrptImage) window.onServerLrptImage(msg);
if (msg.type === "lrpt_progress" && window.onServerLrptProgress) window.onServerLrptProgress(msg);
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress") {
if (msg.type === "wefax" && window.onServerWefax) window.onServerWefax(msg);
if (msg.type === "wefax_progress" && window.onServerWefaxProgress) window.onServerWefaxProgress(msg);
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
window.trx.map?.scheduleStatsRender();
}
@@ -5936,7 +5938,7 @@ function dispatchDecodeBatch(batch) {
if (!Array.isArray(batch) || batch.length === 0) return;
// Record statistics for every message in the batch regardless of dispatch path.
for (const msg of batch) {
if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress") {
if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
}
}
@@ -6023,7 +6025,7 @@ function loadDecodeHistoryOnMainThread(onReady, onError) {
function restoreDecodeHistoryGroup(kind, messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
// Record statistics for restored history messages.
if (kind !== "lrpt_image" && kind !== "lrpt_progress") {
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
for (const msg of messages) {
window.trx.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
}
@@ -6065,6 +6067,10 @@ function restoreDecodeHistoryGroup(kind, messages) {
window.restoreWsprHistory(messages);
return;
}
if (kind === "wefax" && window.restoreWefaxHistory) {
window.restoreWefaxHistory(messages);
return;
}
}
function connectDecode() {
@@ -1,5 +1,5 @@
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"];
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
function decodeCborUint(view, bytes, state, additional) {
const offset = state.offset;
@@ -538,6 +538,7 @@
<button class="sub-tab" data-subtab="wspr">WSPR</button>
<button class="sub-tab" data-subtab="rds">RDS</button>
<button class="sub-tab" data-subtab="sat">SAT</button>
<button class="sub-tab" data-subtab="wefax" id="subtab-wefax">WEFAX</button>
</div>
<div id="subtab-overview" class="sub-tab-panel">
<div class="plugin-item" data-decoder="ais">
@@ -600,6 +601,12 @@
Decodes Meteor-M LRPT (137 MHz QPSK) weather satellite imagery.
</div>
</div>
<div class="plugin-item" data-decoder="wefax">
<strong>WEFAX Decoder</strong>
<div style="color:var(--text-muted); font-size:0.85rem; margin-top:0.2rem;">
Weather Facsimile &mdash; HF/satellite image reception (60/90/120/240 LPM)
</div>
</div>
</div>
<div id="subtab-rds" class="sub-tab-panel" style="display:none;">
<div class="rds-grid">
@@ -919,6 +926,22 @@
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions&hellip;</small>
</div>
</div>
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
<div class="ft8-controls">
<button id="wefax-decode-toggle-btn" type="button">Enable WEFAX</button>
<button id="wefax-clear-btn" type="button" style="margin-left:0.5rem; font-size:0.8rem;">Clear</button>
<small id="wefax-status" style="color:var(--text-muted);">Idle</small>
</div>
<div id="wefax-live-container" style="display:none; margin:0.5rem 0;">
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.3rem;">
<strong>Receiving</strong>
<small id="wefax-live-info" style="color:var(--text-muted);"></small>
</div>
<canvas id="wefax-live-canvas" width="1809" height="800"
style="width:100%; image-rendering:pixelated; background:#000;"></canvas>
</div>
<div id="wefax-gallery" style="display:flex; flex-wrap:wrap; gap:0.5rem;"></div>
</div>
</div>
<div id="tab-map" class="tab-panel" data-tab="map" style="display:none;">
<template id="tmpl-map">
@@ -1476,6 +1499,7 @@
<tr><td>CW</td><td id="about-dec-cw" class="about-status-off">Off</td></tr>
<tr><td>APRS</td><td id="about-dec-aprs" class="about-status-off">Off</td></tr>
<tr><td>Meteor LRPT</td><td id="about-dec-lrpt" class="about-status-off">Off</td></tr>
<tr id="about-dec-wefax"><td>WEFAX</td><td>Weather Facsimile decoder</td></tr>
</table>
</div>
<!-- Integrations -->
@@ -1562,7 +1586,7 @@
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
(function() {
var pluginScripts = {
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js'],
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'],
'map': ['/map-core.js', '/leaflet-ais-tracksymbol.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
'statistics': ['/map-core.js'],
'bookmarks': ['/bookmarks.js'],
@@ -0,0 +1,193 @@
// ---------------------------------------------------------------------------
// wefax.js — WEFAX decoder plugin for trx-frontend-http
// ---------------------------------------------------------------------------
// --- DOM refs ---
var wefaxStatus = document.getElementById('wefax-status');
var wefaxLiveContainer= document.getElementById('wefax-live-container');
var wefaxLiveInfo = document.getElementById('wefax-live-info');
var wefaxLiveCanvas = document.getElementById('wefax-live-canvas');
var wefaxGallery = document.getElementById('wefax-gallery');
var wefaxToggleBtn = document.getElementById('wefax-decode-toggle-btn');
var wefaxClearBtn = document.getElementById('wefax-clear-btn');
// --- State ---
var wefaxImageHistory = [];
var wefaxLiveCtx = null;
var wefaxLiveLineCount = 0;
var wefaxLivePixelsPerLine = 1809;
// --- Helpers ---
function currentWefaxHistoryRetentionMs() {
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
}
function pruneWefaxHistory() {
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// --- Live canvas rendering ---
function resetLiveCanvas(pixelsPerLine) {
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
wefaxLiveCanvas.width = pixelsPerLine;
wefaxLiveCanvas.height = 800;
wefaxLiveCtx = wefaxLiveCanvas.getContext('2d');
wefaxLiveCtx.fillStyle = '#000';
wefaxLiveCtx.fillRect(0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveContainer.style.display = '';
}
function paintLine(lineBytes) {
if (!wefaxLiveCtx) return;
var y = wefaxLiveLineCount;
if (y >= wefaxLiveCanvas.height) {
var old = wefaxLiveCtx.getImageData(0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveCanvas.height *= 2;
wefaxLiveCtx.putImageData(old, 0, 0);
}
var w = wefaxLivePixelsPerLine;
var imgData = wefaxLiveCtx.createImageData(w, 1);
var d = imgData.data;
for (var x = 0; x < w; x++) {
var v = x < lineBytes.length ? lineBytes[x] : 0;
var i = x * 4;
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
// --- Gallery rendering ---
function renderGalleryThumbnail(msg) {
var card = document.createElement('div');
card.className = 'wefax-card';
card.style.cssText =
'border:1px solid var(--border-color); border-radius:4px; ' +
'padding:0.4rem; max-width:280px; cursor:pointer;';
var ts = msg._tsMs ? new Date(msg._tsMs).toLocaleString() : '\u2014';
var info = msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM \u00b7 ' + msg.line_count + ' lines';
if (msg.path) {
card.innerHTML =
'<img src="/images/' + escapeHtml(msg.path.split('/').pop()) + '"' +
' alt="WEFAX" loading="lazy"' +
' style="width:100%; image-rendering:pixelated;" />' +
'<div style="font-size:0.8rem; margin-top:0.2rem;">' + escapeHtml(ts) + '</div>' +
'<div style="font-size:0.75rem; color:var(--text-muted);">' + info + '</div>';
} else {
card.innerHTML =
'<div style="font-size:0.8rem;">' + escapeHtml(ts) + '</div>' +
'<div style="font-size:0.75rem; color:var(--text-muted);">' + info + '</div>';
}
return card;
}
function renderWefaxGallery() {
pruneWefaxHistory();
var frag = document.createDocumentFragment();
for (var i = 0; i < wefaxImageHistory.length; i++) {
frag.appendChild(renderGalleryThumbnail(wefaxImageHistory[i]));
}
wefaxGallery.innerHTML = '';
wefaxGallery.appendChild(frag);
}
function scheduleWefaxGalleryRender() {
if (window.trxScheduleUiFrameJob) {
window.trxScheduleUiFrameJob('wefax-gallery', renderWefaxGallery);
} else {
requestAnimationFrame(renderWefaxGallery);
}
}
// --- SSE event handlers (public API) ---
window.onServerWefaxProgress = function (msg) {
if (msg.line_count <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
if (msg.line_data) {
var binary = atob(msg.line_data);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
if (wefaxLiveInfo) {
wefaxLiveInfo.textContent =
'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
}
if (wefaxStatus) {
wefaxStatus.textContent = 'Receiving \u2014 line ' + msg.line_count;
wefaxStatus.style.color = 'var(--text-accent)';
}
};
window.onServerWefax = function (msg) {
msg._tsMs = msg.ts_ms || Date.now();
wefaxImageHistory.unshift(msg);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxLiveCanvas.width, wefaxLiveLineCount);
wefaxLiveCanvas.height = wefaxLiveLineCount;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
}
if (wefaxStatus) {
wefaxStatus.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
wefaxStatus.style.color = '';
}
};
window.restoreWefaxHistory = function (messages) {
if (!messages || !messages.length) return;
for (var i = 0; i < messages.length; i++) {
messages[i]._tsMs = messages[i].ts_ms || Date.now();
}
wefaxImageHistory = messages.concat(wefaxImageHistory);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
window.pruneWefaxHistoryView = function () {
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
window.resetWefaxHistoryView = function () {
wefaxImageHistory = [];
if (wefaxGallery) wefaxGallery.innerHTML = '';
if (wefaxLiveContainer) wefaxLiveContainer.style.display = 'none';
wefaxLiveCtx = null;
wefaxLiveLineCount = 0;
if (wefaxStatus) {
wefaxStatus.textContent = 'Idle';
wefaxStatus.style.color = '';
}
};
// --- Button handlers ---
if (wefaxClearBtn) {
wefaxClearBtn.addEventListener('click', function () {
fetch('/clear_wefax_decode', { method: 'POST' });
window.resetWefaxHistoryView();
});
}
@@ -55,6 +55,7 @@ define_gz_cache!(gz_ft2_js, status::FT2_JS, "ft2.js");
define_gz_cache!(gz_wspr_js, status::WSPR_JS, "wspr.js");
define_gz_cache!(gz_cw_js, status::CW_JS, "cw.js");
define_gz_cache!(gz_sat_js, status::SAT_JS, "sat.js");
define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js");
define_gz_cache!(gz_bookmarks_js, status::BOOKMARKS_JS, "bookmarks.js");
define_gz_cache!(gz_scheduler_js, status::SCHEDULER_JS, "scheduler.js");
define_gz_cache!(
@@ -325,6 +326,16 @@ pub(crate) async fn sat_js(req: HttpRequest) -> impl Responder {
)
}
#[get("/wefax.js")]
pub(crate) async fn wefax_js(req: HttpRequest) -> impl Responder {
let c = gz_wefax_js();
static_asset_response(
&req,
"application/javascript; charset=utf-8",
c,
)
}
#[get("/bookmarks.js")]
pub(crate) async fn bookmarks_js(req: HttpRequest) -> impl Responder {
let c = gz_bookmarks_js();
@@ -44,6 +44,7 @@ struct DecodeHistoryPayload {
ft4: Vec<trx_core::decode::Ft8Message>,
ft2: Vec<trx_core::decode::Ft8Message>,
wspr: Vec<trx_core::decode::WsprMessage>,
wefax: Vec<trx_core::decode::WefaxMessage>,
}
impl DecodeHistoryPayload {
@@ -57,6 +58,7 @@ impl DecodeHistoryPayload {
+ self.ft4.len()
+ self.ft2.len()
+ self.wspr.len()
+ self.wefax.len()
}
}
@@ -75,6 +77,7 @@ fn collect_decode_history(
ft4: crate::server::audio::snapshot_ft4_history(context, rig_filter),
ft2: crate::server::audio::snapshot_ft2_history(context, rig_filter),
wspr: crate::server::audio::snapshot_wspr_history(context, rig_filter),
wefax: crate::server::audio::snapshot_wefax_history(context, rig_filter),
}
}
@@ -400,10 +403,38 @@ pub async fn toggle_lrpt_decode(
.await
}
#[post("/toggle_wefax_decode")]
pub async fn toggle_wefax_decode(
query: web::Query<RemoteQuery>,
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let enabled = state.get_ref().borrow().decoders.wefax_decode_enabled;
send_command(
&rig_tx,
RigCommand::SetWefaxDecodeEnabled(!enabled),
query.into_inner().remote,
)
.await
}
// ============================================================================
// Decoder clear endpoints
// ============================================================================
#[post("/clear_wefax_decode")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(
&rig_tx,
RigCommand::ResetWefaxDecoder,
query.into_inner().remote,
)
.await
}
#[post("/clear_lrpt_decode")]
pub async fn clear_lrpt_decode(
query: web::Query<RemoteQuery>,
@@ -595,6 +595,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(decoder::toggle_ft2_decode)
.service(decoder::toggle_wspr_decode)
.service(decoder::toggle_lrpt_decode)
.service(decoder::toggle_wefax_decode)
.service(decoder::clear_ais_decode)
.service(decoder::clear_vdes_decode)
.service(decoder::clear_aprs_decode)
@@ -605,6 +606,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(decoder::clear_ft2_decode)
.service(decoder::clear_wspr_decode)
.service(decoder::clear_lrpt_decode)
.service(decoder::clear_wefax_decode)
// Bookmark CRUD
.service(bookmarks::list_bookmarks)
.service(bookmarks::create_bookmark)
@@ -661,6 +663,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::wspr_js)
.service(assets::cw_js)
.service(assets::sat_js)
.service(assets::wefax_js)
.service(assets::bookmarks_js)
.service(assets::scheduler_js)
.service(assets::sat_scheduler_js)
@@ -173,7 +173,7 @@ pub async fn set_vchan_mode(
fn bookmark_decoder_state(
bookmark: &crate::server::bookmarks::Bookmark,
) -> (bool, bool, bool, bool, bool, bool, bool) {
) -> (bool, bool, bool, bool, bool, bool, bool, bool) {
let mut want_aprs = bookmark.mode.trim().eq_ignore_ascii_case("PKT");
let mut want_hf_aprs = false;
let mut want_ft8 = false;
@@ -181,6 +181,7 @@ fn bookmark_decoder_state(
let mut want_ft2 = false;
let mut want_wspr = false;
let mut want_lrpt = false;
let mut want_wefax = false;
for decoder in bookmark
.decoders
@@ -195,6 +196,7 @@ fn bookmark_decoder_state(
"ft2" => want_ft2 = true,
"wspr" => want_wspr = true,
"lrpt" => want_lrpt = true,
"wefax" => want_wefax = true,
_ => {}
}
}
@@ -207,6 +209,7 @@ fn bookmark_decoder_state(
want_ft2,
want_wspr,
want_lrpt,
want_wefax,
)
}
@@ -247,7 +250,7 @@ async fn apply_selected_channel(
let Some(bookmark) = bookmark_store_map.get_for_rig(remote, bookmark_id) else {
return Ok(());
};
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr, want_lrpt) =
let (want_aprs, want_hf_aprs, want_ft8, want_ft4, want_ft2, want_wspr, want_lrpt, want_wefax) =
bookmark_decoder_state(&bookmark);
let desired = [
RigCommand::SetAprsDecodeEnabled(want_aprs),
@@ -257,6 +260,7 @@ async fn apply_selected_channel(
RigCommand::SetFt2DecodeEnabled(want_ft2),
RigCommand::SetWsprDecodeEnabled(want_wspr),
RigCommand::SetLrptDecodeEnabled(want_lrpt),
RigCommand::SetWefaxDecodeEnabled(want_wefax),
];
for cmd in desired {
send_command_to_rig(rig_tx, remote, cmd).await?;
@@ -23,7 +23,8 @@ use tracing::warn;
use uuid::Uuid;
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WsprMessage,
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, VdesMessage, WefaxMessage,
WsprMessage,
};
use trx_frontend::FrontendRuntimeContext;
@@ -296,6 +297,20 @@ fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
prune_wspr_history(context, &mut history);
}
fn record_wefax(context: &FrontendRuntimeContext, msg: WefaxMessage) {
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context
.decode_history
.wefax
.lock()
.expect("wefax history mutex poisoned");
history.push_back((Instant::now(), rig_id, msg));
// Wefax images are large; keep a small history.
while history.len() > 100 {
history.pop_front();
}
}
/// Returns `true` if the entry's rig_id matches the optional filter.
/// `None` filter means "all rigs".
fn matches_rig_filter(entry_rig: Option<&str>, filter: Option<&str>) -> bool {
@@ -471,6 +486,31 @@ pub fn snapshot_wspr_history(
.collect()
}
pub fn snapshot_wefax_history(
context: &FrontendRuntimeContext,
rig_filter: Option<&str>,
) -> Vec<WefaxMessage> {
let history = context
.decode_history
.wefax
.lock()
.expect("wefax history mutex poisoned");
history
.iter()
.filter(|(_, rid, _)| matches_rig_filter(rid.as_deref(), rig_filter))
.map(|(_, _, msg)| msg.clone())
.collect()
}
pub fn clear_wefax_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
.wefax
.lock()
.expect("wefax history mutex poisoned");
history.clear();
}
pub fn clear_aprs_history(context: &FrontendRuntimeContext) {
let mut history = context
.decode_history
@@ -584,6 +624,8 @@ pub fn start_decode_history_collector(context: Arc<FrontendRuntimeContext>) {
DecodedMessage::Ft4(msg) => record_ft4(&context, msg),
DecodedMessage::Ft2(msg) => record_ft2(&context, msg),
DecodedMessage::Wspr(msg) => record_wspr(&context, msg),
DecodedMessage::Wefax(msg) => record_wefax(&context, msg),
DecodedMessage::WefaxProgress(_) => {}
DecodedMessage::LrptImage(_) => {}
DecodedMessage::LrptProgress(_) => {}
},
@@ -1106,6 +1106,7 @@ async fn apply_scheduler_decoders(
let mut want_ft2 = false;
let mut want_wspr = false;
let mut want_lrpt = false;
let mut want_wefax = false;
let mut update_from = |bm: &crate::server::bookmarks::Bookmark| {
for decoder in bm
@@ -1121,6 +1122,7 @@ async fn apply_scheduler_decoders(
"ft2" => want_ft2 = true,
"wspr" => want_wspr = true,
"lrpt" => want_lrpt = true,
"wefax" => want_wefax = true,
_ => {}
}
}
@@ -1139,6 +1141,7 @@ async fn apply_scheduler_decoders(
("FT2", RigCommand::SetFt2DecodeEnabled(want_ft2)),
("WSPR", RigCommand::SetWsprDecodeEnabled(want_wspr)),
("LRPT", RigCommand::SetLrptDecodeEnabled(want_lrpt)),
("WEFAX", RigCommand::SetWefaxDecodeEnabled(want_wefax)),
];
for (label, cmd) in desired {
@@ -28,6 +28,7 @@ pub const FT2_JS: &str = include_str!("../assets/web/plugins/ft2.js");
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
pub const SAT_JS: &str = include_str!("../assets/web/plugins/sat.js");
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
pub const SAT_SCHEDULER_JS: &str = include_str!("../assets/web/plugins/sat-scheduler.js");
+4
View File
@@ -70,6 +70,10 @@ pub const AUDIO_MSG_FT2_DECODE: u8 = 0x15;
pub const AUDIO_MSG_LRPT_IMAGE: u8 = 0x17;
/// Server → client: LRPT decode progress update (JSON `DecodedMessage::LrptProgress`).
pub const AUDIO_MSG_LRPT_PROGRESS: u8 = 0x18;
/// Server → client: WEFAX completed image (JSON `DecodedMessage::Wefax`).
pub const AUDIO_MSG_WEFAX_DECODE: u8 = 0x19;
/// Server → client: WEFAX decode progress (JSON `DecodedMessage::WefaxProgress`).
pub const AUDIO_MSG_WEFAX_PROGRESS: u8 = 0x1A;
/// Maximum payload size for normal messages (1 MB).
const MAX_PAYLOAD_SIZE: u32 = 1_048_576;
+48
View File
@@ -32,6 +32,10 @@ pub enum DecodedMessage {
LrptImage(LrptImage),
#[serde(rename = "lrpt_progress")]
LrptProgress(LrptProgress),
#[serde(rename = "wefax")]
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
}
impl DecodedMessage {
@@ -46,6 +50,8 @@ impl DecodedMessage {
Self::Wspr(m) => m.rig_id = Some(id),
Self::LrptImage(m) => m.rig_id = Some(id),
Self::LrptProgress(m) => m.rig_id = Some(id),
Self::Wefax(m) => m.rig_id = Some(id),
Self::WefaxProgress(m) => m.rig_id = Some(id),
}
}
@@ -60,6 +66,8 @@ impl DecodedMessage {
Self::Wspr(m) => m.rig_id.as_deref(),
Self::LrptImage(m) => m.rig_id.as_deref(),
Self::LrptProgress(m) => m.rig_id.as_deref(),
Self::Wefax(m) => m.rig_id.as_deref(),
Self::WefaxProgress(m) => m.rig_id.as_deref(),
}
}
}
@@ -264,3 +272,43 @@ pub struct LrptImage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ground_track: Option<Vec<[f64; 2]>>,
}
/// A complete WEFAX image.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WefaxMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ts_ms: Option<i64>,
/// Number of image lines decoded.
pub line_count: u32,
/// Detected or configured LPM.
pub lpm: u16,
/// Detected or configured IOC.
pub ioc: u16,
/// Pixels per line (IOC × π, rounded).
pub pixels_per_line: u16,
/// Filesystem path to saved PNG (set on completion).
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// True when image is complete (stop tone received).
pub complete: bool,
}
/// Progress update emitted per-line during active WEFAX reception.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WefaxProgress {
#[serde(skip_serializing_if = "Option::is_none")]
pub rig_id: Option<String>,
/// Number of image lines decoded so far.
pub line_count: u32,
/// Detected or configured LPM.
pub lpm: u16,
/// Detected or configured IOC.
pub ioc: u16,
/// Pixels per line.
pub pixels_per_line: u16,
/// Base64-encoded greyscale line data (one row of pixels).
#[serde(skip_serializing_if = "Option::is_none")]
pub line_data: Option<Vec<u8>>,
}
+2
View File
@@ -32,6 +32,7 @@ pub enum RigCommand {
SetFt2DecodeEnabled(bool),
SetWsprDecodeEnabled(bool),
SetLrptDecodeEnabled(bool),
SetWefaxDecodeEnabled(bool),
ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder,
@@ -40,6 +41,7 @@ pub enum RigCommand {
ResetFt2Decoder,
ResetWsprDecoder,
ResetLrptDecoder,
ResetWefaxDecoder,
SetBandwidth(u32),
SetSdrGain(f64),
SetSdrLnaGain(f64),
@@ -460,6 +460,8 @@ pub fn command_from_rig_command(cmd: RigCommand) -> Box<dyn RigCommandHandler> {
| RigCommand::ResetWsprDecoder
| RigCommand::SetLrptDecodeEnabled(_)
| RigCommand::ResetLrptDecoder
| RigCommand::SetWefaxDecodeEnabled(_)
| RigCommand::ResetWefaxDecoder
| RigCommand::SetBandwidth(_)
| RigCommand::SetSdrGain(_)
| RigCommand::SetSdrLnaGain(_)
+4
View File
@@ -31,6 +31,8 @@ pub struct DecoderConfig {
#[serde(default)]
pub lrpt_decode_enabled: bool,
#[serde(default)]
pub wefax_decode_enabled: bool,
#[serde(default)]
pub recorder_enabled: bool,
}
@@ -57,6 +59,8 @@ pub struct DecoderResetSeqs {
pub wspr_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub lrpt_decode_reset_seq: u64,
#[serde(default, skip_serializing)]
pub wefax_decode_reset_seq: u64,
}
/// Simple transceiver state representation held by the rig task.
+8
View File
@@ -130,6 +130,14 @@ pub const DECODER_REGISTRY: &[DecoderDescriptor] = &[
background_decode: false,
bookmark_selectable: true,
},
DecoderDescriptor {
id: "wefax",
label: "WEFAX",
activation: DecoderActivation::Toggle,
active_modes: &["USB", "LSB", "AM"],
background_decode: false,
bookmark_selectable: true,
},
];
// ============================================================================
+3 -1
View File
@@ -121,7 +121,8 @@ define_command_mapping! {
ResetFt4Decoder <=> ResetFt4Decoder,
ResetFt2Decoder <=> ResetFt2Decoder,
ResetWsprDecoder <=> ResetWsprDecoder,
ResetLrptDecoder <=> ResetLrptDecoder;
ResetLrptDecoder <=> ResetLrptDecoder,
ResetWefaxDecoder <=> ResetWefaxDecoder;
// ── Single-field struct <=> tuple ────────────────────────────────
field:
@@ -138,6 +139,7 @@ define_command_mapping! {
SetFt2DecodeEnabled { enabled } <=> SetFt2DecodeEnabled,
SetWsprDecodeEnabled { enabled } <=> SetWsprDecodeEnabled,
SetLrptDecodeEnabled { enabled } <=> SetLrptDecodeEnabled,
SetWefaxDecodeEnabled { enabled } <=> SetWefaxDecodeEnabled,
SetBandwidth { bandwidth_hz } <=> SetBandwidth,
SetSdrGain { gain_db } <=> SetSdrGain,
SetSdrLnaGain { gain_db } <=> SetSdrLnaGain,
+2
View File
@@ -38,6 +38,7 @@ pub enum ClientCommand {
SetFt2DecodeEnabled { enabled: bool },
SetWsprDecodeEnabled { enabled: bool },
SetLrptDecodeEnabled { enabled: bool },
SetWefaxDecodeEnabled { enabled: bool },
ResetAprsDecoder,
ResetHfAprsDecoder,
ResetCwDecoder,
@@ -46,6 +47,7 @@ pub enum ClientCommand {
ResetFt2Decoder,
ResetWsprDecoder,
ResetLrptDecoder,
ResetWefaxDecoder,
SetBandwidth { bandwidth_hz: u32 },
SetSdrGain { gain_db: f64 },
SetSdrLnaGain { gain_db: f64 },
+1
View File
@@ -39,6 +39,7 @@ trx-aprs = { path = "../decoders/trx-aprs" }
trx-cw = { path = "../decoders/trx-cw" }
trx-decode-log = { path = "../decoders/trx-decode-log" }
trx-ftx = { path = "../decoders/trx-ftx" }
trx-wefax = { path = "../decoders/trx-wefax" }
trx-wspr = { path = "../decoders/trx-wspr" }
trx-wxsat = { path = "../decoders/trx-wxsat" }
trx-protocol = { path = "../trx-protocol" }
+195 -2
View File
@@ -28,7 +28,7 @@ use trx_core::audio::{
write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
AUDIO_MSG_LRPT_PROGRESS,
AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS,
AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME, AUDIO_MSG_VCHAN_ALLOCATED,
AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ, AUDIO_MSG_VCHAN_MODE,
AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB, AUDIO_MSG_VDES_DECODE,
@@ -36,7 +36,7 @@ use trx_core::audio::{
};
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, LrptProgress,
VdesMessage,
VdesMessage, WefaxMessage,
WsprMessage,
};
use trx_core::rig::state::{RigMode, RigState};
@@ -58,6 +58,7 @@ const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WEFAX_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
/// Maximum entries per decoder history queue. Prevents unbounded memory growth
/// on busy channels (e.g. AIS near a port). Oldest entries are evicted when
/// the limit is reached, independent of the time-based pruning.
@@ -228,6 +229,7 @@ pub struct DecoderHistories {
pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
/// Approximate total entry count across all decoders, maintained
/// atomically so `estimated_total_count()` avoids 9 lock acquisitions.
total_count: AtomicUsize,
@@ -264,6 +266,7 @@ impl DecoderHistories {
ft2: Mutex::new(VecDeque::new()),
wspr: Mutex::new(VecDeque::new()),
lrpt: Mutex::new(VecDeque::new()),
wefax: Mutex::new(VecDeque::new()),
total_count: AtomicUsize::new(0),
})
}
@@ -691,6 +694,46 @@ impl DecoderHistories {
self.adjust_total_count(before, 0);
}
// --- WEFAX ---
fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
let cutoff = Instant::now() - WEFAX_HISTORY_RETENTION;
while let Some((ts, _)) = history.front() {
if *ts < cutoff {
history.pop_front();
} else {
break;
}
}
}
pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.push_back((Instant::now(), msg));
Self::prune_wefax(&mut h);
enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
self.adjust_total_count(before, h.len());
}
pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
Self::prune_wefax(&mut h);
self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect()
}
pub fn clear_wefax_history(&self) {
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.clear();
self.adjust_total_count(before, 0);
}
/// Returns a quick (non-pruning) estimate of the total number of history
/// entries across all decoders, used for pre-allocating the replay blob.
///
@@ -2629,6 +2672,147 @@ async fn finalize_lrpt_pass(
decoder.reset();
}
// ---------------------------------------------------------------------------
// WEFAX decoder task
// ---------------------------------------------------------------------------
/// Run the WEFAX decoder task. Processes PCM when enabled and rig mode matches.
pub async fn run_wefax_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
mut state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
histories: Arc<DecoderHistories>,
) {
use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};
info!(
"WEFAX decoder started ({}Hz, {} ch)",
sample_rate, channels
);
let config = WefaxConfig::default();
let mut decoder = WefaxDecoder::new(sample_rate, config);
let mut was_active = false;
let mut last_reset_seq: u64 = 0;
let is_wefax_mode = |mode: &RigMode| {
matches!(mode, RigMode::USB | RigMode::LSB | RigMode::AM)
};
let mut active = state_rx.borrow().decoders.wefax_decode_enabled
&& is_wefax_mode(&state_rx.borrow().status.mode);
loop {
if !active {
match state_rx.changed().await {
Ok(()) => {
let state = state_rx.borrow();
active = state.decoders.wefax_decode_enabled
&& is_wefax_mode(&state.status.mode);
if active {
pcm_rx = pcm_rx.resubscribe();
}
}
Err(_) => break,
}
continue;
}
tokio::select! {
recv = pcm_rx.recv() => {
match recv {
Ok(frame) => {
let (process_enabled, reset_seq) = {
let state = state_rx.borrow();
(
state.decoders.wefax_decode_enabled
&& is_wefax_mode(&state.status.mode),
state.reset_seqs.wefax_decode_reset_seq,
)
};
if reset_seq != last_reset_seq {
last_reset_seq = reset_seq;
decoder.reset();
info!("WEFAX decoder reset (seq={})", last_reset_seq);
pcm_rx = pcm_rx.resubscribe();
continue;
}
if !process_enabled {
if was_active {
decoder.reset();
was_active = false;
}
active = false;
continue;
}
let mono = if channels > 1 {
let num_frames = frame.len() / channels as usize;
let mut mono = Vec::with_capacity(num_frames);
for i in 0..num_frames {
mono.push(frame[i * channels as usize]);
}
mono
} else {
frame
};
was_active = true;
let events = tokio::task::block_in_place(|| {
let _span = info_span!("wefax_decode").entered();
decoder.process_samples(&mono)
});
let latest_reset_seq =
state_rx.borrow().reset_seqs.wefax_decode_reset_seq;
if latest_reset_seq != reset_seq {
last_reset_seq = latest_reset_seq;
decoder.reset();
info!("WEFAX decoder reset (seq={})", last_reset_seq);
pcm_rx = pcm_rx.resubscribe();
continue;
}
for evt in events {
match evt {
WefaxEvent::Progress(progress, _line_data) => {
let _ = decode_tx.send(
DecodedMessage::WefaxProgress(progress),
);
}
WefaxEvent::Complete(msg) => {
histories.record_wefax_message(msg.clone());
let _ =
decode_tx.send(DecodedMessage::Wefax(msg));
}
}
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!("WEFAX decoder: dropped {} PCM frames", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
changed = state_rx.changed() => {
match changed {
Ok(()) => {
let state = state_rx.borrow();
active = state.decoders.wefax_decode_enabled
&& is_wefax_mode(&state.status.mode);
}
Err(_) => break,
}
}
}
}
info!("WEFAX decoder stopped");
}
// ---------------------------------------------------------------------------
// Virtual-channel audio support
// ---------------------------------------------------------------------------
@@ -3239,6 +3423,11 @@ async fn handle_audio_client(
DecodedMessage::LrptImage,
AUDIO_MSG_LRPT_IMAGE
);
push_history!(
histories.snapshot_wefax_history(),
DecodedMessage::Wefax,
AUDIO_MSG_WEFAX_DECODE
);
(blob, count)
};
@@ -3325,6 +3514,8 @@ async fn handle_audio_client(
DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
};
if let Ok(json) = serde_json::to_vec(&msg) {
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
@@ -3355,6 +3546,8 @@ async fn handle_audio_client(
DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
};
if let Ok(json) = serde_json::to_vec(&msg) {
if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
+13 -1
View File
@@ -18,7 +18,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use pickledb::{PickleDb, PickleDbDumpPolicy, SerializationMethod};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use trx_core::decode::{AisMessage, AprsPacket, CwEvent, Ft8Message, VdesMessage, WsprMessage};
use trx_core::decode::{
AisMessage, AprsPacket, CwEvent, Ft8Message, VdesMessage, WefaxMessage, WsprMessage,
};
use crate::audio::DecoderHistories;
@@ -131,6 +133,11 @@ pub fn load_all(db: &PickleDb, rig_id: &str, histories: &Arc<DecoderHistories>)
h.push_back(e);
}
}
if let Ok(mut h) = histories.wefax.lock() {
for e in load_key::<WefaxMessage>(db, &k("wefax")) {
h.push_back(e);
}
}
}
/// Flush `histories` to the database under `rig_id`-prefixed keys and sync.
@@ -170,6 +177,11 @@ pub fn flush_all(db: &mut PickleDb, rig_id: &str, histories: &Arc<DecoderHistori
drop(h);
save_key(db, &k("wspr"), &snapshot);
}
if let Ok(h) = histories.wefax.lock() {
let snapshot = h.clone();
drop(h);
save_key(db, &k("wefax"), &snapshot);
}
let _ = db.dump();
}
+15
View File
@@ -817,6 +817,21 @@ fn spawn_rig_audio_stack(
_ = wait_for_shutdown(lrpt_shutdown_rx) => {}
}
}));
// Spawn WEFAX decoder task
let wefax_pcm_rx = pcm_tx.subscribe();
let wefax_state_rx = state_rx.clone();
let wefax_decode_tx = decode_tx.clone();
let wefax_sr = rig_cfg.audio.sample_rate;
let wefax_ch = rig_cfg.audio.channels;
let wefax_shutdown_rx = shutdown_rx.clone();
let wefax_histories = histories.clone();
handles.push(tokio::spawn(async move {
tokio::select! {
_ = audio::run_wefax_decoder(wefax_sr, wefax_ch as u16, wefax_pcm_rx, wefax_state_rx, wefax_decode_tx, wefax_histories) => {}
_ = wait_for_shutdown(wefax_shutdown_rx) => {}
}
}));
}
if rig_cfg.audio.tx_enabled {
+12
View File
@@ -601,6 +601,18 @@ async fn process_command(
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetWefaxDecodeEnabled(en) => {
ctx.state.decoders.wefax_decode_enabled = en;
info!("WEFAX decode {}", if en { "enabled" } else { "disabled" });
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::ResetWefaxDecoder => {
ctx.histories.clear_wefax_history();
ctx.state.reset_seqs.wefax_decode_reset_seq += 1;
let _ = ctx.state_tx.send(ctx.state.clone());
return snapshot_from(ctx.state);
}
RigCommand::SetBandwidth(hz) => {
if let Some(sdr) = ctx.rig.as_sdr() {
if let Err(e) = sdr.set_bandwidth(hz).await {