[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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user