Compare commits

..

5 Commits

Author SHA1 Message Date
sjg 1911a109e2 [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:40:21 +02:00
sjg 7178ebeb23 [fix](trx-frontend-http): use double-rAF for map sizing after tab visibility change
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-20 00:51:34 +02:00
sjg c92428b78b [fix](trx-rs): transfer WEFAX PNG data from server to client for remote image serving
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-20 00:28:55 +02:00
sjg 5de972dd61 [fix](trx-rs): GQRX-style S-meter ballistics across DSP and frontend
DSP: 400 ms attack / 1.0 s decay IIR on IQ power (block-rate corrected).
JS:  asymmetric EMA (α=0.08 attack, α=0.03 decay) with rAF coalescing.
CSS: bar transition 150 ms → 300 ms ease-out.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-19 23:35:33 +02:00
sjg aed9483659 [fix](trx-backend-soapysdr): slow down WFM S-meter to 200 ms attack / 600 ms decay
50 ms attack was still too twitchy for WFM — block-to-block power
noise in the constant-envelope FM signal made the meter jitter.
200 ms attack (~6 frames) and 600 ms decay (~18 frames) give a
smooth, traditional meter feel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-04-19 23:23:43 +02:00
11 changed files with 126 additions and 20 deletions
Generated
+2
View File
@@ -3178,6 +3178,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"actix-ws", "actix-ws",
"base64",
"brotli 7.0.0", "brotli 7.0.0",
"bytes", "bytes",
"dirs", "dirs",
@@ -3261,6 +3262,7 @@ dependencies = [
name = "trx-server" name = "trx-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64",
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
+12
View File
@@ -489,12 +489,23 @@ impl WefaxDecoder {
let ppl = WefaxConfig::pixels_per_line(ioc); let ppl = WefaxConfig::pixels_per_line(ioc);
let mut path_str = None; let mut path_str = None;
let mut png_data = None;
// Save PNG if output directory is configured. // Save PNG if output directory is configured.
if let Some(ref dir) = self.config.output_dir { if let Some(ref dir) = self.config.output_dir {
let output_path = PathBuf::from(dir); let output_path = PathBuf::from(dir);
match image.save_png(&output_path, self.freq_hz, &self.mode) { match image.save_png(&output_path, self.freq_hz, &self.mode) {
Ok(p) => { 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()); path_str = Some(p.to_string_lossy().into_owned());
} }
Err(e) => { Err(e) => {
@@ -512,6 +523,7 @@ impl WefaxDecoder {
ioc, ioc,
pixels_per_line: ppl, pixels_per_line: ppl,
path: path_str, path: path_str,
png_data,
complete: true, complete: true,
})); }));
} }
@@ -16,6 +16,7 @@ tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
base64 = "0.22"
actix-web = "4.4" actix-web = "4.4"
actix-ws = "0.3" actix-ws = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
@@ -4485,7 +4485,16 @@ function _initMapWhenReady() {
if (loadingEl) loadingEl.classList.add("is-hidden"); if (loadingEl) loadingEl.classList.add("is-hidden");
window.trx.map.initAprsMap(); window.trx.map.initAprsMap();
window.trx.map.sizeAprsMapToViewport(); window.trx.map.sizeAprsMapToViewport();
if (window.trx.map.aprsMap) setTimeout(() => window.trx.map.aprsMap.invalidateSize(), 50); // 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();
});
});
return; return;
} }
// Not ready yet — show overlay and poll until both are available. // Not ready yet — show overlay and poll until both are available.
@@ -7006,9 +7015,18 @@ function stopSpectrumStreaming() {
// ── /meter (fast signal-strength) streaming ───────────────────────────────── // ── /meter (fast signal-strength) streaming ─────────────────────────────────
// Dedicated SSE channel pushed at ~30 Hz by trx-server; bypasses /events so // Dedicated SSE channel pushed at ~30 Hz by trx-server; bypasses /events so
// meter frames are never gated by full-RigState diffing. Synchronous DOM // meter frames are never gated by full-RigState diffing.
// write per frame — no rAF coalescing, per user requirement that it "feel //
// instant" on the frontend. // 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;
function scheduleMeterReconnect() { function scheduleMeterReconnect() {
if (meterReconnectTimer !== null) return; if (meterReconnectTimer !== null) return;
meterReconnectTimer = setTimeout(() => { meterReconnectTimer = setTimeout(() => {
@@ -7019,6 +7037,24 @@ function scheduleMeterReconnect() {
function applyMeterSample(dbm) { function applyMeterSample(dbm) {
if (typeof dbm !== "number" || !Number.isFinite(dbm)) return; 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; prevRenderData.sigDbm = dbm;
const sUnits = dbmToSUnits(dbm); const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits; sigLastSUnits = sUnits;
@@ -7059,6 +7095,7 @@ function stopMeterStreaming() {
clearTimeout(meterReconnectTimer); clearTimeout(meterReconnectTimer);
meterReconnectTimer = null; meterReconnectTimer = null;
} }
meterSmoothedDbm = null; // reset so next rig starts fresh
} }
// ── Rendering ──────────────────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────────────────
@@ -1848,10 +1848,13 @@
initAprsMap(); initAprsMap();
sizeAprsMapToViewport(); sizeAprsMapToViewport();
if (aprsMap) { if (aprsMap) {
setTimeout(() => { requestAnimationFrame(() => {
aprsMap.invalidateSize(); requestAnimationFrame(() => {
aprsMap.setView([lat, lon], 13); sizeAprsMapToViewport();
}, 50); aprsMap.invalidateSize();
aprsMap.setView([lat, lon], 13);
});
});
} }
}; };
@@ -1896,6 +1899,7 @@
const center = locatorMarkerCenter(marker); const center = locatorMarkerCenter(marker);
const focusMarker = () => { const focusMarker = () => {
if (!aprsMap || !marker) return; if (!aprsMap || !marker) return;
sizeAprsMapToViewport();
aprsMap.invalidateSize(); aprsMap.invalidateSize();
if (center) { if (center) {
const targetZoom = Math.max(aprsMap.getZoom() || 0, 7); const targetZoom = Math.max(aprsMap.getZoom() || 0, 7);
@@ -1910,7 +1914,9 @@
if (typeof marker.openPopup === "function") marker.openPopup(); if (typeof marker.openPopup === "function") marker.openPopup();
}; };
focusMarker(); focusMarker();
setTimeout(focusMarker, 60); requestAnimationFrame(() => {
requestAnimationFrame(focusMarker);
});
return true; 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; } .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 { 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 { 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 150ms ease; } .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-value { font-size: 0.95rem; color: var(--text-heading); min-width: 48px; text-align: right; } .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 { 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; } .meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: var(--btn-bg); border: 1px solid var(--border-light); overflow: hidden; }
@@ -16,6 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use actix_web::{get, web, Error, HttpRequest, HttpResponse}; use actix_web::{get, web, Error, HttpRequest, HttpResponse};
use actix_ws::Message; use actix_ws::Message;
use base64::Engine as _;
use bytes::Bytes; use bytes::Bytes;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -297,7 +298,30 @@ fn record_wspr(context: &FrontendRuntimeContext, msg: WsprMessage) {
prune_wspr_history(context, &mut history); prune_wspr_history(context, &mut history);
} }
fn record_wefax(context: &FrontendRuntimeContext, msg: WefaxMessage) { 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;
let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context)); let rig_id = msg.rig_id.clone().or_else(|| active_rig_id(context));
let mut history = context let mut history = context
.decode_history .decode_history
+4
View File
@@ -291,6 +291,10 @@ pub struct WefaxMessage {
/// Filesystem path to saved PNG (set on completion). /// Filesystem path to saved PNG (set on completion).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>, 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). /// True when image is complete (stop tone received).
pub complete: bool, pub complete: bool,
} }
+1
View File
@@ -14,6 +14,7 @@ ft2 = ["trx-ftx/ft2", "trx-protocol/ft2"]
soapysdr = ["trx-backend/soapysdr"] soapysdr = ["trx-backend/soapysdr"]
[dependencies] [dependencies]
base64 = "0.22"
flate2 = { workspace = true } flate2 = { workspace = true }
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }
tokio-serial = { workspace = true } tokio-serial = { workspace = true }
+18 -1
View File
@@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use base64::Engine as _;
use bytes::Bytes; use bytes::Bytes;
use flate2::write::GzEncoder; use flate2::write::GzEncoder;
use flate2::Compression; use flate2::Compression;
@@ -709,6 +710,8 @@ impl DecoderHistories {
if msg.ts_ms.is_none() { if msg.ts_ms.is_none() {
msg.ts_ms = Some(current_timestamp_ms()); 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 mut h = lock_or_recover(&self.wefax, "wefax_history");
let before = h.len(); let before = h.len();
h.push_back((Instant::now(), msg)); h.push_back((Instant::now(), msg));
@@ -722,7 +725,21 @@ impl DecoderHistories {
let before = h.len(); let before = h.len();
Self::prune_wefax(&mut h); Self::prune_wefax(&mut h);
self.adjust_total_count(before, h.len()); self.adjust_total_count(before, h.len());
h.iter().map(|(_, msg)| msg.clone()).collect() 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()
} }
pub fn clear_wefax_history(&self) { pub fn clear_wefax_history(&self) {
@@ -308,20 +308,22 @@ pub struct ChannelDsp {
impl ChannelDsp { impl ChannelDsp {
/// Compute asymmetric IIR coefficients for S-meter envelope tracking. /// Compute asymmetric IIR coefficients for S-meter envelope tracking.
/// ///
/// Attack: ~50 ms time constant (responsive but visually stable). /// Attack: ~400 ms — rises over ~12 frames at 30 Hz.
/// Decay: ~300 ms time constant (slow fall for stable reading). /// Decay: ~1.0 s — falls over ~30 frames, readable.
/// ///
/// Note: these alphas are applied once per decimated *block*, not per /// Modelled after GQRX meter ballistics. Deliberately slower than
/// sample, with block-rate correction (`1 (1−α)^N`). The 50 ms /// the IARU analog-meter spec because a digital bar at 30 fps is
/// attack gives ~3-frame settling at 30 Hz meter refresh — fast /// visually noisier than a physical needle with mechanical inertia.
/// enough to follow signal changes, smooth enough to avoid jitter. ///
/// Note: alphas are applied once per decimated *block*, not per
/// sample, with block-rate correction (`1 (1−α)^N`).
fn smeter_alphas(channel_sample_rate: u32) -> (f32, f32) { fn smeter_alphas(channel_sample_rate: u32) -> (f32, f32) {
if channel_sample_rate == 0 { if channel_sample_rate == 0 {
return (0.3, 0.01); return (0.3, 0.01);
} }
let sr = channel_sample_rate as f32; let sr = channel_sample_rate as f32;
let attack = (1.0 - (-1.0 / (sr * 0.050)).exp()).min(1.0); // τ = 50 ms let attack = (1.0 - (-1.0 / (sr * 0.400)).exp()).min(1.0); // τ = 400 ms
let decay = (1.0 - (-1.0 / (sr * 0.300)).exp()).min(1.0); // τ = 300 ms let decay = (1.0 - (-1.0 / (sr * 1.000)).exp()).min(1.0); // τ = 1.0 s
(attack, decay) (attack, decay)
} }