daa31fb6e5
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>
177 lines
6.3 KiB
JavaScript
177 lines
6.3 KiB
JavaScript
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
|
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;
|
|
if (additional < 24) return additional;
|
|
if (additional === 24) {
|
|
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
|
state.offset += 1;
|
|
return bytes[offset];
|
|
}
|
|
if (additional === 25) {
|
|
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
|
state.offset += 2;
|
|
return view.getUint16(offset);
|
|
}
|
|
if (additional === 26) {
|
|
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
|
state.offset += 4;
|
|
return view.getUint32(offset);
|
|
}
|
|
if (additional === 27) {
|
|
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
|
const value = view.getBigUint64(offset);
|
|
state.offset += 8;
|
|
const numeric = Number(value);
|
|
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
|
return numeric;
|
|
}
|
|
throw new Error("Unsupported CBOR additional info");
|
|
}
|
|
|
|
function decodeCborFloat16(bits) {
|
|
const sign = (bits & 0x8000) ? -1 : 1;
|
|
const exponent = (bits >> 10) & 0x1f;
|
|
const fraction = bits & 0x03ff;
|
|
if (exponent === 0) {
|
|
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
|
|
}
|
|
if (exponent === 0x1f) {
|
|
return fraction === 0 ? sign * Infinity : Number.NaN;
|
|
}
|
|
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
|
}
|
|
|
|
function decodeCborItem(view, bytes, state) {
|
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
|
const initial = bytes[state.offset++];
|
|
const major = initial >> 5;
|
|
const additional = initial & 0x1f;
|
|
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
|
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
|
|
if (major === 2) {
|
|
const length = decodeCborUint(view, bytes, state, additional);
|
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
|
const chunk = bytes.slice(state.offset, state.offset + length);
|
|
state.offset += length;
|
|
return Array.from(chunk);
|
|
}
|
|
if (major === 3) {
|
|
const length = decodeCborUint(view, bytes, state, additional);
|
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
|
const chunk = bytes.subarray(state.offset, state.offset + length);
|
|
state.offset += length;
|
|
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
|
}
|
|
if (major === 4) {
|
|
const length = decodeCborUint(view, bytes, state, additional);
|
|
const items = new Array(length);
|
|
for (let i = 0; i < length; i += 1) {
|
|
items[i] = decodeCborItem(view, bytes, state);
|
|
}
|
|
return items;
|
|
}
|
|
if (major === 5) {
|
|
const length = decodeCborUint(view, bytes, state, additional);
|
|
const value = {};
|
|
for (let i = 0; i < length; i += 1) {
|
|
const key = decodeCborItem(view, bytes, state);
|
|
value[String(key)] = decodeCborItem(view, bytes, state);
|
|
}
|
|
return value;
|
|
}
|
|
if (major === 6) {
|
|
decodeCborUint(view, bytes, state, additional);
|
|
return decodeCborItem(view, bytes, state);
|
|
}
|
|
if (major === 7) {
|
|
if (additional === 20) return false;
|
|
if (additional === 21) return true;
|
|
if (additional === 22) return null;
|
|
if (additional === 23) return undefined;
|
|
if (additional === 25) {
|
|
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
|
const bits = view.getUint16(state.offset);
|
|
state.offset += 2;
|
|
return decodeCborFloat16(bits);
|
|
}
|
|
if (additional === 26) {
|
|
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
|
const value = view.getFloat32(state.offset);
|
|
state.offset += 4;
|
|
return value;
|
|
}
|
|
if (additional === 27) {
|
|
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
|
const value = view.getFloat64(state.offset);
|
|
state.offset += 8;
|
|
return value;
|
|
}
|
|
}
|
|
throw new Error("Unsupported CBOR major type");
|
|
}
|
|
|
|
function decodeCborPayload(buffer) {
|
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
const state = { offset: 0 };
|
|
const value = decodeCborItem(view, bytes, state);
|
|
if (state.offset !== bytes.length) {
|
|
throw new Error("Unexpected trailing bytes in decode history payload");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function fetchAndDecodeHistory(url, batchLimit) {
|
|
self.postMessage({ type: "status", phase: "fetching" });
|
|
const resp = await fetch(url, { credentials: "same-origin" });
|
|
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
|
|
const payload = await resp.arrayBuffer();
|
|
if (!payload || payload.byteLength === 0) {
|
|
self.postMessage({ type: "start", total: 0 });
|
|
self.postMessage({ type: "done", total: 0 });
|
|
return;
|
|
}
|
|
|
|
self.postMessage({ type: "status", phase: "decoding" });
|
|
const history = decodeCborPayload(payload);
|
|
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
|
const items = history && Array.isArray(history[key]) ? history[key] : [];
|
|
return sum + items.length;
|
|
}, 0);
|
|
self.postMessage({ type: "start", total });
|
|
|
|
let processed = 0;
|
|
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
|
|
|
for (const kind of HISTORY_GROUP_KEYS) {
|
|
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
|
|
if (items.length === 0) continue;
|
|
for (let index = 0; index < items.length; index += safeLimit) {
|
|
const messages = items.slice(index, index + safeLimit);
|
|
processed += messages.length;
|
|
self.postMessage({
|
|
type: "group",
|
|
kind,
|
|
messages,
|
|
processed,
|
|
total,
|
|
});
|
|
}
|
|
}
|
|
self.postMessage({ type: "done", total });
|
|
}
|
|
|
|
self.onmessage = (event) => {
|
|
const data = event?.data || {};
|
|
if (data?.type !== "fetch-history") return;
|
|
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
|
.catch((err) => {
|
|
self.postMessage({
|
|
type: "error",
|
|
message: err && err.message ? err.message : String(err || "unknown worker failure"),
|
|
});
|
|
});
|
|
};
|