[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:
@@ -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 — 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…</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user