From 5de972dd611e65e3d9b7319d1eb1d3dea3915ee8 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 19 Apr 2026 23:35:33 +0200 Subject: [PATCH] [fix](trx-rs): GQRX-style S-meter ballistics across DSP and frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 34 +++++++++++++++++-- .../trx-frontend-http/assets/web/style.css | 2 +- .../trx-backend-soapysdr/src/dsp/channel.rs | 14 +++++--- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index b1ed35d..fa702e3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -7006,9 +7006,18 @@ 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. Synchronous DOM -// write per frame — no rAF coalescing, per user requirement that it "feel -// instant" on the frontend. +// 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; + function scheduleMeterReconnect() { if (meterReconnectTimer !== null) return; meterReconnectTimer = setTimeout(() => { @@ -7019,6 +7028,24 @@ 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; @@ -7059,6 +7086,7 @@ function stopMeterStreaming() { clearTimeout(meterReconnectTimer); meterReconnectTimer = null; } + meterSmoothedDbm = null; // reset so next rig starts fresh } // ── Rendering ──────────────────────────────────────────────────────────────── diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 8655362..b10aef2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -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 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; } .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; } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs index 7a89654..0281bbe 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs @@ -308,18 +308,22 @@ pub struct ChannelDsp { impl ChannelDsp { /// Compute asymmetric IIR coefficients for S-meter envelope tracking. /// - /// Attack: ~200 ms time constant (~6 frames at 30 Hz refresh). - /// Decay: ~600 ms time constant (~18 frames — smooth fallback). + /// Attack: ~400 ms — rises over ~12 frames at 30 Hz. + /// 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 + /// 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`). 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.200)).exp()).min(1.0); // τ = 200 ms - let decay = (1.0 - (-1.0 / (sr * 0.600)).exp()).min(1.0); // τ = 600 ms + 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 (attack, decay) }