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>
27 KiB
WEFAX / Radiofax Decoder Implementation Plan
Crate:
trx-wefax—src/decoders/trx-wefax/Status: Implemented (Phases 1–3b) — 2026-04-02
1. Overview
WEFAX (Weather Facsimile, ITU-T T.4 / WMO) is an analog image transmission mode used by meteorological agencies worldwide (NOAA, DWD, JMH, etc.) on HF and satellite downlinks. The decoder converts FM-modulated audio tones into greyscale (or colour-composited) image lines.
Goals
- Pure Rust, zero C FFI dependencies (matching project conventions).
- Multi-speed support: 60, 90, 120, 240 LPM (lines per minute).
- Multi-IOC support: 288 and 576 (Index of Cooperation — defines line pixel width).
- Automatic start/stop detection via APT tones.
- Phase-aligned line assembly from phasing signal.
- Incremental image output (line-by-line progress + final PNG).
- Follow existing decoder patterns (
process_block/decode_if_ready).
2. WEFAX Signal Structure
Carrier (1900 Hz center, ±400 Hz deviation)
Black = 1500 Hz
White = 2300 Hz
(linear mapping between frequency and luminance)
Transmission sequence:
┌─────────────┐
│ Start tone │ 300 Hz (5s) or 675 Hz (3s) — selects IOC 576 / 288
├─────────────┤
│ Phasing │ >95% white line + narrow black pulse — phase alignment
│ (30 lines) │
├─────────────┤
│ Image lines │ N lines at configured LPM
├─────────────┤
│ Stop tone │ 450 Hz (5s) — signals end of transmission
└─────────────┘
Key parameters
| Parameter | IOC 576 | IOC 288 |
|---|---|---|
| Pixels per line | 1809 | 904 |
| Line duration (120 LPM) | 500 ms | 500 ms |
| Line duration (60 LPM) | 1000 ms | 1000 ms |
| Pixel clock | ~3618 px/s (120 LPM) | ~1808 px/s (120 LPM) |
Pixel count per line = IOC × π (rounded: 576×π ≈ 1809, 288×π ≈ 904).
3. Architecture
graph TD
PCM["PCM audio (f32, 48 kHz)"] --> RS["Resampler (to internal rate)"]
RS --> FM["FM Discriminator"]
FM --> LPF["Low-pass filter (anti-alias)"]
LPF --> TD["Tone Detector (APT start/stop)"]
LPF --> PA["Phase Aligner"]
PA --> LS["Line Slicer"]
LS --> IMG["Image Assembler"]
IMG --> OUT["WefaxMessage (line / image)"]
TD --> SM["State Machine"]
SM -->|controls| PA
SM -->|controls| LS
Internal sample rate
Resample input to 11,025 Hz (sufficient for 2300 Hz max tone with comfortable margin; matches common WEFAX decoder practice and keeps DSP cost low).
4. Module Layout
src/decoders/trx-wefax/
Cargo.toml
src/
lib.rs # Public API: WefaxDecoder, WefaxConfig, WefaxEvent
decoder.rs # Top-level decoder state machine + process_block/decode_if_ready
demod.rs # FM discriminator (instantaneous frequency from analytic signal)
tone_detect.rs # Goertzel-based APT tone detector (300/450/675 Hz)
phase.rs # Phasing signal detector and line-start alignment
line_slicer.rs # Pixel clock recovery, line buffer assembly
resampler.rs # Polyphase rational resampler (48k → 11025)
image.rs # Image buffer, PNG encoding, optional colour compositing
config.rs # WefaxConfig: speed, IOC, auto-detect, output path
5. Core Types
5.1 Configuration
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,
}
5.2 Decoder state machine
pub enum WefaxState {
/// Listening for APT start tone.
Idle,
/// Start tone detected; waiting for phasing signal.
StartDetected { ioc: u16, tone_start_sample: u64 },
/// Receiving phasing lines; aligning line-start phase.
Phasing { ioc: u16, lpm: u16, phase_offset: Option<usize> },
/// Actively decoding image lines.
Receiving { ioc: u16, lpm: u16, line_number: u32 },
/// Stop tone detected; finalising image.
Stopping,
}
5.3 Output messages (for trx-core::DecodedMessage)
/// A complete or in-progress WEFAX image.
pub struct WefaxMessage {
pub rig_id: Option<String>,
pub ts_ms: Option<i64>,
/// 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 (IOC × π, rounded).
pub pixels_per_line: u16,
/// Filesystem path to saved PNG (set on completion).
pub path: Option<String>,
/// True when image is complete (stop tone received).
pub complete: bool,
}
/// Progress update emitted every N lines during active reception.
pub struct WefaxProgress {
pub rig_id: Option<String>,
pub line_count: u32,
pub lpm: u16,
pub ioc: u16,
}
6. DSP Pipeline Detail
6.1 Resampling
Rational polyphase resampler: 48000 → 11025 Hz (ratio 441/1920, simplified
from 11025/48000). Follow docs/Optimization-Guidelines.md polyphase
resampler guidance. Same pattern as FT8 decoder's 48k→12k resampler.
6.2 FM Discriminator
Compute instantaneous frequency from the analytic signal:
- Hilbert transform (FIR, 65-tap) to produce analytic signal
z[n]. - Instantaneous frequency:
f[n] = arg(z[n] · conj(z[n-1])) / (2π·Ts) - Map frequency to luminance:
pixel = clamp((f - 1500) / 800, 0, 1).
The Hilbert + frequency discriminator approach avoids PLL complexity and works well for the relatively low data rate of WEFAX.
6.3 APT Tone Detection
Use Goertzel filters at three frequencies (matching trx-cw pattern):
| Tone | Frequency | Meaning |
|---|---|---|
| Start (IOC 576) | 300 Hz | Begin reception, IOC=576 |
| Start (IOC 288) | 675 Hz | Begin reception, IOC=288 |
| Stop | 450 Hz | End of transmission |
Detection window: ~200 ms (2205 samples at 11025 Hz). Require sustained detection for ≥1.5 s to confirm (debounce against noise). Energy ratio vs broadband noise for reliability.
6.4 Phasing Signal Detection
During phasing, each line is >95% white (2300 Hz) with a narrow black pulse (~5% of line width) at the line-start position.
- After start tone, begin accumulating demodulated samples.
- Slice into line-duration windows (e.g., 500 ms for 120 LPM).
- Cross-correlate against expected phasing template (short black pulse).
- Average pulse position over 10+ phasing lines → line-start phase offset.
- Transition to
Receivingonce phase is stable (variance < 2 samples).
6.5 Line Slicing and Pixel Clock
Once phased:
- Accumulate demodulated (frequency → luminance) samples.
- At each line boundary (determined by LPM and phase offset), extract
one line of
pixels_per_linevalues via linear interpolation from the sample buffer. - Push completed line into the image assembler.
- Emit
WefaxProgressevery 50 lines (configurable).
6.6 Image Assembly
- Maintain a
Vec<Vec<u8>>of greyscale lines (0–255). - On stop tone or manual stop: encode to 8-bit greyscale PNG.
- Save to
output_dirwith filename pattern:WEFAX-{YYYY}-{MM}-{DD}T{HH}{mm}{ss}-IOC{ioc}-{lpm}lpm.png - Return
WefaxMessagewithcomplete: trueandpathset.
7. Integration with trx-rs
7.1 Workspace registration
Add to root Cargo.toml workspace members:
"src/decoders/trx-wefax"
7.2 trx-core changes
Add variants to DecodedMessage:
#[serde(rename = "wefax")]
Wefax(WefaxMessage),
#[serde(rename = "wefax_progress")]
WefaxProgress(WefaxProgress),
Update set_rig_id() / rig_id() match arms.
7.3 trx-server integration
Add run_wefax_decoder() in audio.rs following the existing pattern:
pub async fn run_wefax_decoder(
sample_rate: u32,
channels: u16,
mut pcm_rx: broadcast::Receiver<Vec<f32>>,
state_rx: watch::Receiver<RigState>,
decode_tx: broadcast::Sender<DecodedMessage>,
logs: Option<Arc<DecoderLoggers>>,
histories: Arc<DecoderHistories>,
)
Spawn in main.rs alongside other decoders, gated by mode (USB/LSB on
HF WEFAX frequencies).
7.4 History and logging
- Add
wefax: Arc<Mutex<VecDeque<WefaxMessage>>>toDecoderHistories. - Add optional
wefaxlogger toDecoderLoggers(JSON Lines).
7.5 Frontend exposure
The web frontend follows the existing decoder plugin pattern used by WSPR,
FT8, AIS, etc. WEFAX is unique among decoders because it produces images
rather than text rows, so the UI uses a <canvas> for live line-by-line
rendering instead of the tabular layout used by other decoders.
7.5.1 Rust backend wiring (trx-frontend-http)
src/status.rs — embed the plugin script:
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
src/api/assets.rs — define the gzip-cached route:
define_gz_cache!(gz_wefax_js, status::WEFAX_JS, "wefax.js");
#[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)
}
src/api/decoder.rs — add endpoints:
#[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
}
#[post("/clear_wefax_decode")]
pub async fn clear_wefax_decode(
query: web::Query<RemoteQuery>,
context: web::Data<Arc<FrontendRuntimeContext>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
crate::server::audio::clear_wefax_history(context.get_ref());
send_command(
&rig_tx,
RigCommand::ResetWefaxDecoder,
query.into_inner().remote,
)
.await
}
src/api/mod.rs — register in configure():
.service(decoder::toggle_wefax_decode)
.service(decoder::clear_wefax_decode)
.service(assets::wefax_js)
Decode history — add "wefax" key to the CBOR payload returned
by GET /decode/history, containing Vec<WefaxMessage> (completed images
only; in-progress images are streamed via SSE).
SSE /decode stream — broadcast two event shapes:
{"wefax_progress": {"line_count": 142, "lpm": 120, "ioc": 576, "pixels_per_line": 1809,
"line_data": "<base64-encoded u8 greyscale row>"}}
{"wefax": {"ts_ms": 1712000000000, "line_count": 800, "lpm": 120, "ioc": 576,
"pixels_per_line": 1809, "complete": true,
"path": "/images/WEFAX-2026-04-02T1430-IOC576-120lpm.png"}}
wefax_progress events carry a base64 line_data field (one image row of
greyscale bytes) so the browser can paint each line as it arrives without
needing a separate WebSocket channel.
Decoder registry — add entry to DECODER_REGISTRY in
trx-protocol:
DecoderRegistryEntry {
id: "wefax",
label: "WEFAX",
activation: "toggle", // enable/disable button
active_modes: &["usb", "lsb", "am"],
background_decode: false,
bookmark_selectable: true,
}
7.5.2 HTML additions (index.html)
Sub-tab button (inside .sub-tab-bar, after the existing decoder
buttons):
<button class="sub-tab" data-subtab="wefax" id="subtab-wefax">WEFAX</button>
Sub-tab panel (alongside other sub-tab-panel divs):
<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>
<!-- Live image canvas — painted line-by-line during reception -->
<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>
<!-- Gallery of completed images -->
<div id="wefax-gallery" style="display:flex; flex-wrap:wrap; gap:0.5rem;"></div>
</div>
Overview section (inside the digital-modes overview panel):
<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 — HF/satellite image reception (60/90/120/240 LPM)
</div>
</div>
About section (in the About tab decoder list):
<tr id="about-dec-wefax"><td>WEFAX</td><td>Weather Facsimile decoder</td></tr>
7.5.3 Plugin script registration
index.html plugin map — add '/wefax.js' to the
'digital-modes' array in pluginScripts:
var pluginScripts = {
'digital-modes': ['/ft8.js', ..., '/wefax.js'],
// ...
};
7.5.4 SSE dispatch in app.js
Add WEFAX to the decode event dispatcher (inside decodeSource.onmessage):
if (msg.wefax_progress && window.onServerWefaxProgress) {
window.onServerWefaxProgress(msg.wefax_progress);
}
if (msg.wefax && window.onServerWefax) {
window.onServerWefax(msg.wefax);
}
Add "wefax" to the decode history restore loop:
// In loadDecodeHistoryOnMainThread / worker dispatch:
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs",
"cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
Add WEFAX to restoreDecodeHistoryGroup():
case "wefax":
if (window.restoreWefaxHistory) window.restoreWefaxHistory(messages);
break;
7.5.5 Plugin file (assets/web/plugins/wefax.js)
Full plugin structure following the project's vanilla-JS decoder plugin pattern:
// ---------------------------------------------------------------------------
// wefax.js — WEFAX decoder plugin for trx-frontend-http
// ---------------------------------------------------------------------------
// --- DOM refs ---
const wefaxStatus = document.getElementById('wefax-status');
const wefaxLiveContainer= document.getElementById('wefax-live-container');
const wefaxLiveInfo = document.getElementById('wefax-live-info');
const wefaxLiveCanvas = document.getElementById('wefax-live-canvas');
const wefaxGallery = document.getElementById('wefax-gallery');
const wefaxToggleBtn = document.getElementById('wefax-decode-toggle-btn');
const wefaxClearBtn = document.getElementById('wefax-clear-btn');
// --- State ---
let wefaxImageHistory = []; // completed WefaxMessage objects
let wefaxLiveCtx = null; // canvas 2D context
let wefaxLiveLineCount = 0; // lines painted so far
let wefaxLivePixelsPerLine = 1809;
// --- Helpers ---
function currentWefaxHistoryRetentionMs() {
return window.getDecodeHistoryRetentionMs?.() || 24 * 60 * 60 * 1000;
}
function pruneWefaxHistory() {
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(m => (m._tsMs || 0) > cutoff);
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"');
}
// --- Live canvas rendering ---
/** Reset canvas for a new image reception. */
function resetLiveCanvas(pixelsPerLine) {
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
wefaxLiveCanvas.width = pixelsPerLine;
wefaxLiveCanvas.height = 800; // grows if needed
wefaxLiveCtx = wefaxLiveCanvas.getContext('2d');
wefaxLiveCtx.fillStyle = '#000';
wefaxLiveCtx.fillRect(0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveContainer.style.display = '';
}
/** Append one greyscale line (Uint8Array) to the live canvas. */
function paintLine(lineBytes) {
if (!wefaxLiveCtx) return;
const y = wefaxLiveLineCount;
// Grow canvas vertically if needed (double height strategy).
if (y >= wefaxLiveCanvas.height) {
const old = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveCanvas.height);
wefaxLiveCanvas.height *= 2;
wefaxLiveCtx.putImageData(old, 0, 0);
}
const w = wefaxLivePixelsPerLine;
const imgData = wefaxLiveCtx.createImageData(w, 1);
const d = imgData.data;
for (let x = 0; x < w; x++) {
const v = x < lineBytes.length ? lineBytes[x] : 0;
const 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) {
const 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;';
const ts = msg._tsMs
? new Date(msg._tsMs).toLocaleString()
: '—';
const info = `${msg.ioc} IOC · ${msg.lpm} LPM · ${msg.line_count} lines`;
// If a server path is available, show a thumbnail linking to it.
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();
const frag = document.createDocumentFragment();
for (const msg of wefaxImageHistory) {
frag.appendChild(renderGalleryThumbnail(msg));
}
wefaxGallery.innerHTML = '';
wefaxGallery.appendChild(frag);
}
function scheduleWefaxGalleryRender() {
if (window.trxScheduleUiFrameJob) {
window.trxScheduleUiFrameJob('wefax-gallery', renderWefaxGallery);
} else {
requestAnimationFrame(renderWefaxGallery);
}
}
// --- SSE event handlers (public API) ---
/** Called for each wefax_progress SSE event (one image line). */
window.onServerWefaxProgress = function (msg) {
// First progress event of a new image → reset canvas.
if (msg.line_count <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
// Decode base64 line_data → Uint8Array → paint.
if (msg.line_data) {
const binary = atob(msg.line_data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
// Update status text.
if (wefaxLiveInfo) {
wefaxLiveInfo.textContent =
`Line ${msg.line_count} · ${msg.ioc} IOC · ${msg.lpm} LPM`;
}
if (wefaxStatus) {
wefaxStatus.textContent = `Receiving — line ${msg.line_count}`;
wefaxStatus.style.color = 'var(--text-accent)';
}
};
/** Called when a complete WEFAX image is received. */
window.onServerWefax = function (msg) {
msg._tsMs = msg.ts_ms || Date.now();
wefaxImageHistory.unshift(msg);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
// Finalise live canvas — trim height to actual line count.
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
const trimmed = wefaxLiveCtx.getImageData(
0, 0, wefaxLiveCanvas.width, wefaxLiveLineCount);
wefaxLiveCanvas.height = wefaxLiveLineCount;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
}
if (wefaxStatus) {
wefaxStatus.textContent = `Complete — ${msg.line_count} lines`;
wefaxStatus.style.color = '';
}
};
/** Batch restore from decode history (page load). */
window.restoreWefaxHistory = function (messages) {
if (!messages || !messages.length) return;
for (const m of messages) {
m._tsMs = m.ts_ms || Date.now();
}
wefaxImageHistory = messages.concat(wefaxImageHistory);
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Called by history retention pruning cycle. */
window.pruneWefaxHistoryView = function () {
pruneWefaxHistory();
scheduleWefaxGalleryRender();
};
/** Full reset (rig change, clear). */
window.resetWefaxHistoryView = function () {
wefaxImageHistory = [];
wefaxGallery.innerHTML = '';
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();
});
}
7.5.6 Data flow summary
sequenceDiagram
participant Server as trx-server (wefax decoder)
participant SSE as SSE /decode
participant Plugin as wefax.js
participant Canvas as <canvas>
participant Gallery as Gallery div
Server->>SSE: wefax_progress (line_data base64)
SSE->>Plugin: onServerWefaxProgress()
Plugin->>Canvas: paintLine() — one greyscale row
Note over Server: ...repeats per line...
Server->>SSE: wefax (complete=true, path)
SSE->>Plugin: onServerWefax()
Plugin->>Canvas: trim canvas to final height
Plugin->>Gallery: renderGalleryThumbnail()
7.5.7 Image serving
Completed PNG files saved by the decoder need an HTTP route for browser
access. Add a static-file route in assets.rs:
#[get("/images/{filename}")]
pub(crate) async fn wefax_image(
req: HttpRequest,
path: web::Path<String>,
) -> impl Responder {
// Serve from WefaxConfig::output_dir, validate filename (no path traversal).
// Content-Type: image/png, Cache-Control: public, max-age=86400.
}
Register in api/mod.rs:
.service(assets::wefax_image)
7.5.8 Decode history worker update
Add "wefax" to HISTORY_GROUP_KEYS in decode-history-worker.js:
const HISTORY_GROUP_KEYS = [
"ais", "vdes", "aprs", "hf_aprs", "cw",
"ft8", "ft4", "ft2", "wspr", "wefax"
];
8. Implementation Phases
Phase 1: Core DSP (MVP) ✅
- ✅ Resampler — 48k→11025 polyphase resampler with tests.
- ✅ FM discriminator — Hilbert FIR + instantaneous freq, verify against synthetic 1500–2300 Hz sweeps.
- ✅ Tone detector — Goertzel at 300/450/675 Hz with debounce.
- ✅ Line slicer — Fixed-config (manual LPM+IOC) line extraction.
- ✅ Image buffer + PNG — Greyscale line accumulation,
pngcrate for encoding.
Deliverable: decode a known WEFAX WAV recording at a single speed/IOC.
Phase 2: Automatic Detection ✅
- ✅ State machine — Full
Idle→StartDetected→Phasing→Receiving→Stoppingtransitions driven by tone detector. - ✅ Phase alignment — Cross-correlation phasing detector.
- ✅ Auto IOC/LPM — 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 ✅
- ✅
trx-coremessage types —WefaxMessage,WefaxProgressinDecodedMessage. - ✅
trx-servertask —run_wefax_decoder(), history, logging. - ✅ Protocol registry —
DECODER_REGISTRYentry for"wefax".
Deliverable: backend wefax decoding with SSE event broadcast.
Phase 3b: Frontend Wiring ✅
- ✅ Rust asset pipeline —
status.rsembed,assets.rsgzip cache + route,decoder.rstoggle/clear endpoints,api/mod.rsregistration (§7.5.1). - ✅ HTML scaffold — sub-tab button, sub-tab panel with canvas + gallery, overview entry, about row (§7.5.2).
- ✅ Plugin loading — add
/wefax.jstopluginScripts'digital-modes'array (§7.5.3). - ✅ SSE dispatch —
wefax/wefax_progresshandlers inapp.jsdecode event dispatcher (§7.5.4). - ✅
wefax.jsplugin — live canvas rendering, gallery thumbnails, history restore, toggle/clear wiring (§7.5.5). - Image serving —
/images/{filename}static route for completed PNGs (§7.5.7). (deferred: images served from output_dir) - ✅ History worker — add
"wefax"toHISTORY_GROUP_KEYS(§7.5.8).
Deliverable: end-to-end live WEFAX decoding with in-browser image preview.
Phase 4: Polish
- Multi-speed runtime switching — handle back-to-back transmissions at different LPM within one session.
- Slant correction — fine-tune sample clock drift compensation using phasing pulse tracking.
- Colour compositing — optional IR + visible overlay for satellite WEFAX (future).
- Test suite — synthetic signal generation, round-trip tests, edge cases (partial images, noise, frequency offset).
9. Dependencies
[dependencies]
trx-core = { path = "../../trx-core" }
rustfft = "6" # Hilbert transform FIR via FFT overlap-save (optional)
png = "0.17" # PNG encoding (lightweight, no image full dep)
No additional heavy dependencies required. The DSP components (Goertzel,
polyphase resampler, Hilbert FIR) are small enough to implement inline,
consistent with the pure-Rust approach of trx-rds, trx-cw, and
trx-ftx.
10. Testing Strategy
| Test | Method |
|---|---|
| FM discriminator accuracy | Synthesise known-frequency tones, verify ±1 Hz |
| Tone detection | Inject 300/450/675 Hz bursts, verify timing |
| Phase alignment | Synthetic phasing signal with known pulse position |
| Line pixel accuracy | Known gradient pattern → verify pixel values |
| Full decode round-trip | Reference WEFAX WAV → compare output PNG against known-good |
| Multi-speed switching | Sequential 120 LPM + 60 LPM images in one stream |
| Noise resilience | Add white noise at various SNR, verify graceful degradation |
11. References
- ITU-R BT.601 (facsimile signal characteristics)
- WMO Manual on the GTS, Attachment II-13 (HF radiofax schedule/format)
- NOAA Radiofax Charts: frequency schedules and IOC/LPM per product
- Existing open-source implementations:
fldigiWEFAX module,multimon-ng