Compare commits

..

1 Commits

Author SHA1 Message Date
sjg 83e9ba9fb1 [fix](trx-wefax): revert slant correction and silent-drop verifier
Per-line cross-correlation slant tracking (d487711) shifted every line by up to \u00b16 samples with a 0.01 deadband, so image-content and shot-noise variance dominated the drift estimate and garbled the output. The unverified-reception verifier (76f9953) then silently dropped the entire capture at line 40 when correlation never settled. Together they made valid transmissions look like decoder failures.

Revert both: fixed-period extraction restored, carrier-loss watchdog ungated, transition_to_receiving no longer takes a verified flag. Phasing timeout fallback and variance-based auto-start kept. This returns the decoder to fldigi-equivalent behaviour.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-21 21:39:40 +02:00
11 changed files with 20 additions and 126 deletions
Generated
-2
View File
@@ -3178,7 +3178,6 @@ version = "0.1.0"
dependencies = [
"actix-web",
"actix-ws",
"base64",
"brotli 7.0.0",
"bytes",
"dirs",
@@ -3262,7 +3261,6 @@ dependencies = [
name = "trx-server"
version = "0.1.0"
dependencies = [
"base64",
"bytes",
"chrono",
"clap",
-12
View File
@@ -489,23 +489,12 @@ impl WefaxDecoder {
let ppl = WefaxConfig::pixels_per_line(ioc);
let mut path_str = None;
let mut png_data = 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, self.freq_hz, &self.mode) {
Ok(p) => {
// Read back the PNG bytes for remote client transfer.
match std::fs::read(&p) {
Ok(bytes) => {
png_data =
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
Err(e) => {
eprintln!("WEFAX: failed to read PNG for transfer: {}", e);
}
}
path_str = Some(p.to_string_lossy().into_owned());
}
Err(e) => {
@@ -523,7 +512,6 @@ impl WefaxDecoder {
ioc,
pixels_per_line: ppl,
path: path_str,
png_data,
complete: true,
}));
}
@@ -16,7 +16,6 @@ tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
base64 = "0.22"
actix-web = "4.4"
actix-ws = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
@@ -4485,16 +4485,7 @@ function _initMapWhenReady() {
if (loadingEl) loadingEl.classList.add("is-hidden");
window.trx.map.initAprsMap();
window.trx.map.sizeAprsMapToViewport();
// The map panel was just made visible (display:none → ""); the browser
// may not have laid it out yet, so getBoundingClientRect() can return
// stale/zero dimensions. Double-rAF ensures a full layout pass has
// completed before we re-measure and tell Leaflet about its real size.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.trx.map.sizeAprsMapToViewport();
if (window.trx.map.aprsMap) window.trx.map.aprsMap.invalidateSize();
});
});
if (window.trx.map.aprsMap) setTimeout(() => window.trx.map.aprsMap.invalidateSize(), 50);
return;
}
// Not ready yet — show overlay and poll until both are available.
@@ -7015,18 +7006,9 @@ function stopSpectrumStreaming() {
// ── /meter (fast signal-strength) streaming ─────────────────────────────────
// Dedicated SSE channel pushed at ~30 Hz by trx-server; bypasses /events so
// meter frames are never gated by full-RigState diffing.
//
// Client-side asymmetric EMA smoothing (GQRX-style ballistics):
// attack τ ≈ 400 ms — rises in ~12 frames at 30 Hz
// decay τ ≈ 1.0 s — falls in ~30 frames, readable
// DOM updates are coalesced via requestAnimationFrame so the bar
// animates at display refresh rate, not SSE rate.
const METER_ATTACK_ALPHA = 0.08; // per-frame at ~30 Hz ≈ 400 ms τ
const METER_DECAY_ALPHA = 0.03; // per-frame at ~30 Hz ≈ 1.0 s τ
let meterSmoothedDbm = null;
let meterRafPending = false;
// meter frames are never gated by full-RigState diffing. Synchronous DOM
// write per frame — no rAF coalescing, per user requirement that it "feel
// instant" on the frontend.
function scheduleMeterReconnect() {
if (meterReconnectTimer !== null) return;
meterReconnectTimer = setTimeout(() => {
@@ -7037,24 +7019,6 @@ function scheduleMeterReconnect() {
function applyMeterSample(dbm) {
if (typeof dbm !== "number" || !Number.isFinite(dbm)) return;
// Asymmetric EMA: fast attack, slow decay.
if (meterSmoothedDbm === null) {
meterSmoothedDbm = dbm;
} else {
const alpha = dbm > meterSmoothedDbm ? METER_ATTACK_ALPHA : METER_DECAY_ALPHA;
meterSmoothedDbm += alpha * (dbm - meterSmoothedDbm);
}
// Coalesce DOM writes to display refresh rate.
if (!meterRafPending) {
meterRafPending = true;
requestAnimationFrame(flushMeterDom);
}
}
function flushMeterDom() {
meterRafPending = false;
const dbm = meterSmoothedDbm;
if (dbm === null) return;
prevRenderData.sigDbm = dbm;
const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits;
@@ -7095,7 +7059,6 @@ function stopMeterStreaming() {
clearTimeout(meterReconnectTimer);
meterReconnectTimer = null;
}
meterSmoothedDbm = null; // reset so next rig starts fresh
}
// ── Rendering ────────────────────────────────────────────────────────────────
@@ -1848,13 +1848,10 @@
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
sizeAprsMapToViewport();
aprsMap.invalidateSize();
aprsMap.setView([lat, lon], 13);
});
});
setTimeout(() => {
aprsMap.invalidateSize();
aprsMap.setView([lat, lon], 13);
}, 50);
}
};
@@ -1899,7 +1896,6 @@
const center = locatorMarkerCenter(marker);
const focusMarker = () => {
if (!aprsMap || !marker) return;
sizeAprsMapToViewport();
aprsMap.invalidateSize();
if (center) {
const targetZoom = Math.max(aprsMap.getZoom() || 0, 7);
@@ -1914,9 +1910,7 @@
if (typeof marker.openPopup === "function") marker.openPopup();
};
focusMarker();
requestAnimationFrame(() => {
requestAnimationFrame(focusMarker);
});
setTimeout(focusMarker, 60);
return true;
};
@@ -1356,7 +1356,7 @@ small { color: var(--text-muted); }
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: var(--btn-bg); color: var(--text); font-size: 0.82rem; border: 1px solid var(--border-light); margin-left: 6px; }
.signal { display: flex; gap: 0.6rem; align-items: center; }
.signal-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
.signal-bar-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-red)); transition: width 300ms ease-out; }
.signal-bar-fill { height: 100%; width: 0%; background: linear-gradient(90deg, var(--accent-green), var(--accent-yellow), var(--accent-red)); transition: width 150ms ease; }
.signal-value { font-size: 0.95rem; color: var(--text-heading); min-width: 48px; text-align: right; }
.meter { display: flex; gap: 0.6rem; align-items: center; }
.meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
@@ -16,7 +16,6 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use actix_web::{get, web, Error, HttpRequest, HttpResponse};
use actix_ws::Message;
use base64::Engine as _;
use bytes::Bytes;
use serde::Deserialize;
use tokio::sync::broadcast;
@@ -298,30 +297,7 @@ fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
prune_wspr_history(context, &mut history);
}
fn record_wefax(context: &FrontendRuntimeContext, mut msg: WefaxMessage) {
// If the server sent PNG data, save it to the local cache so the
// `/images/` endpoint can serve it.
if let Some(ref data) = msg.png_data {
if let Some(ref path) = msg.path {
if let Some(filename) = std::path::Path::new(path).file_name() {
let dir = dirs::cache_dir()
.unwrap_or_else(|| std::path::PathBuf::from(".cache"))
.join("trx-rs")
.join("wefax");
if std::fs::create_dir_all(&dir).is_ok() {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data) {
let local_path = dir.join(filename);
if let Err(e) = std::fs::write(&local_path, &bytes) {
tracing::warn!("WEFAX: failed to save local image: {}", e);
}
}
}
}
}
}
// Strip bulk data before storing in memory.
msg.png_data = None;
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
-4
View File
@@ -291,10 +291,6 @@ pub struct WefaxMessage {
/// Filesystem path to saved PNG (set on completion).
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Base64-encoded PNG data for transfer to remote clients.
/// Populated by the server when sending, stripped before storing in history.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub png_data: Option<String>,
/// True when image is complete (stop tone received).
pub complete: bool,
}
-1
View File
@@ -14,7 +14,6 @@ ft2 = ["trx-ftx/ft2", "trx-protocol/ft2"]
soapysdr = ["trx-backend/soapysdr"]
[dependencies]
base64 = "0.22"
flate2 = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tokio-serial = { workspace = true }
+1 -18
View File
@@ -12,7 +12,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use base64::Engine as _;
use bytes::Bytes;
use flate2::write::GzEncoder;
use flate2::Compression;
@@ -710,8 +709,6 @@ impl DecoderHistories {
if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms());
}
// Strip bulk PNG data before storing in memory/persistence.
msg.png_data = None;
let mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len();
h.push_back((Instant::now(), msg));
@@ -725,21 +722,7 @@ impl DecoderHistories {
let before = h.len();
Self::prune_wefax(&mut h);
self.adjust_total_count(before, h.len());
h.iter()
.map(|(_, msg)| {
let mut m = msg.clone();
// Re-read PNG from disk so remote clients can save a local copy.
if m.png_data.is_none() {
if let Some(ref path) = m.path {
if let Ok(bytes) = std::fs::read(path) {
m.png_data =
Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
}
}
m
})
.collect()
h.iter().map(|(_, msg)| msg.clone()).collect()
}
pub fn clear_wefax_history(&self) {
@@ -308,22 +308,20 @@ pub struct ChannelDsp {
impl ChannelDsp {
/// Compute asymmetric IIR coefficients for S-meter envelope tracking.
///
/// Attack: ~400 ms — rises over ~12 frames at 30 Hz.
/// Decay: ~1.0 s — falls over ~30 frames, readable.
/// Attack: ~50 ms time constant (responsive but visually stable).
/// Decay: ~300 ms time constant (slow fall for stable reading).
///
/// Modelled after GQRX meter ballistics. Deliberately slower than
/// the IARU analog-meter spec because a digital bar at 30 fps is
/// visually noisier than a physical needle with mechanical inertia.
///
/// Note: alphas are applied once per decimated *block*, not per
/// sample, with block-rate correction (`1 (1−α)^N`).
/// Note: these alphas are applied once per decimated *block*, not per
/// sample, with block-rate correction (`1 (1−α)^N`). The 50 ms
/// attack gives ~3-frame settling at 30 Hz meter refresh — fast
/// enough to follow signal changes, smooth enough to avoid jitter.
fn smeter_alphas(channel_sample_rate: u32) -> (f32, f32) {
if channel_sample_rate == 0 {
return (0.3, 0.01);
}
let sr = channel_sample_rate as f32;
let attack = (1.0 - (-1.0 / (sr * 0.400)).exp()).min(1.0); // τ = 400 ms
let decay = (1.0 - (-1.0 / (sr * 1.000)).exp()).min(1.0); // τ = 1.0 s
let attack = (1.0 - (-1.0 / (sr * 0.050)).exp()).min(1.0); // τ = 50 ms
let decay = (1.0 - (-1.0 / (sr * 0.300)).exp()).min(1.0); // τ = 300 ms
(attack, decay)
}