[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>
This commit is contained in:
2026-04-19 23:35:33 +02:00
parent aed9483659
commit 5de972dd61
3 changed files with 41 additions and 9 deletions
@@ -7006,9 +7006,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 +7028,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 +7086,7 @@ function stopMeterStreaming() {
clearTimeout(meterReconnectTimer); clearTimeout(meterReconnectTimer);
meterReconnectTimer = null; meterReconnectTimer = null;
} }
meterSmoothedDbm = null; // reset so next rig starts fresh
} }
// ── Rendering ──────────────────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────────────────
@@ -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; }
@@ -308,18 +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: ~200 ms time constant (~6 frames at 30 Hz refresh). /// Attack: ~400 ms — rises over ~12 frames at 30 Hz.
/// Decay: ~600 ms time constant (~18 frames — smooth fallback). /// 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`). /// 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.200)).exp()).min(1.0); // τ = 200 ms let attack = (1.0 - (-1.0 / (sr * 0.400)).exp()).min(1.0); // τ = 400 ms
let decay = (1.0 - (-1.0 / (sr * 0.600)).exp()).min(1.0); // τ = 600 ms let decay = (1.0 - (-1.0 / (sr * 1.000)).exp()).min(1.0); // τ = 1.0 s
(attack, decay) (attack, decay)
} }