From 4f8658b773631a5244f88c59064495464e85ea7b Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Feb 2026 02:25:39 +0100 Subject: [PATCH] [fix](trx-frontend-http): fix waterfall freeze, RDS bandwidth, add jog multiplier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix waterfall overview freezing at steady state by tracking a monotonic push counter instead of row array length — the array size stays constant once the waterfall is full, so the previous row-count comparison never triggered the incremental draw path. Fix WFM RDS not decoding when switching to WFM from a narrowband mode: set_mode now resets audio_bandwidth_hz to the mode-appropriate default (180 kHz for WFM) before rebuilding the FIR, preventing the 57 kHz RDS subcarrier from being filtered out. Add 1×/10×/100× multiplier button group next to the jog unit selector. jogUnit × jogMult gives the effective jog step; both are persisted to localStorage independently. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 96 +++++++++++++------ .../trx-frontend-http/assets/web/index.html | 8 ++ .../trx-frontend-http/assets/web/style.css | 32 +++++++ .../trx-backend-soapysdr/src/dsp.rs | 16 ++++ 4 files changed, 124 insertions(+), 28 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 7b24342..b5a026f 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 @@ -349,7 +349,9 @@ let sigMeasureWeighted = 0; let sigMeasurePeak = null; let lastFreqHz = null; let centerFreqDirty = false; -let jogStep = loadSetting("jogStep", 1000); +let jogUnit = loadSetting("jogUnit", 1000); // base unit: 1, 1000, 1000000 +let jogMult = loadSetting("jogMult", 1); // multiplier: 1, 10, 100 +let jogStep = Math.max(jogUnit * jogMult, 1); let minFreqStepHz = 1; const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; function vfoColor(idx) { @@ -625,14 +627,15 @@ let reconnectTimer = null; let overviewSignalSamples = []; let overviewSignalTimer = null; let overviewWaterfallRows = []; +let overviewWaterfallPushCount = 0; // monotonically increments on every push const HEADER_SIG_WINDOW_MS = 10_000; // Offscreen waterfall cache — reused across frames to avoid full redraws -let _wfOC = null; // OffscreenCanvas -let _wfOCPalKey = ""; // palette signature when offscreen was last built -let _wfOCRowCount = 0; // number of rows currently rendered into offscreen +let _wfOC = null; // OffscreenCanvas +let _wfOCPalKey = ""; // palette signature when offscreen was last built +let _wfOCPushCount = 0; // overviewWaterfallPushCount when offscreen was last updated -function _wfResetOffscreen() { _wfOC = null; _wfOCRowCount = 0; _wfOCPalKey = ""; } +function _wfResetOffscreen() { _wfOC = null; _wfOCPushCount = 0; _wfOCPalKey = ""; } function _wfPalKey(pal) { return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}`; } @@ -700,6 +703,7 @@ function overviewVisibleBinWindow(data, binCount) { function pushOverviewWaterfallFrame(data) { if (!overviewCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return; overviewWaterfallRows.push(data.bins.slice()); + overviewWaterfallPushCount++; trimOverviewWaterfallRows(); scheduleOverviewDraw(); } @@ -762,27 +766,29 @@ function drawOverviewWaterfall(ctx, w, h, pal) { const iH = Math.ceil(h); const palKey = _wfPalKey(pal); const steadyState = rows.length >= maxVisible; + // How many rows were pushed since the offscreen was last updated + const newPushes = overviewWaterfallPushCount - _wfOCPushCount; // Detect conditions that require a full redraw const sizeChanged = !_wfOC || _wfOC.width !== iW || _wfOC.height !== iH; const palChanged = _wfOCPalKey !== palKey; - const rowsShrank = rows.length < _wfOCRowCount; - const needsFull = sizeChanged || palChanged || rowsShrank || _wfOCRowCount === 0; + const needsFull = sizeChanged || palChanged || _wfOCPushCount === 0; if (sizeChanged || !_wfOC) { _wfOC = new OffscreenCanvas(iW, iH); - _wfOCRowCount = 0; + _wfOCPushCount = 0; } const oct = _wfOC.getContext("2d"); if (needsFull) { oct.clearRect(0, 0, iW, iH); _wfDrawRows(oct, rows, 0, rows.length, iW, iH, pal); - _wfOCRowCount = rows.length; - _wfOCPalKey = palKey; - } else if (steadyState && rows.length > _wfOCRowCount) { - // Steady state: scroll up and paint only the new rows at the bottom - const newCount = rows.length - _wfOCRowCount; + _wfOCPushCount = overviewWaterfallPushCount; + _wfOCPalKey = palKey; + } else if (steadyState && newPushes > 0) { + // Steady state: scroll up and paint only the new rows at the bottom. + // newPushes new rows are at the tail of `rows`; each replaces one old row. + const newCount = Math.min(newPushes, rows.length); const rowH = iH / rows.length; const scrollPx = Math.round(newCount * rowH); if (scrollPx > 0 && scrollPx < iH) { @@ -791,7 +797,7 @@ function drawOverviewWaterfall(ctx, w, h, pal) { oct.clearRect(0, iH - scrollPx, iW, scrollPx); } _wfDrawRows(oct, rows, rows.length - newCount, rows.length, iW, iH, pal); - _wfOCRowCount = rows.length; + _wfOCPushCount = overviewWaterfallPushCount; } ctx.drawImage(_wfOC, 0, 0, w, h); @@ -1649,6 +1655,16 @@ const jogIndicator = document.getElementById("jog-indicator"); const jogDownBtn = document.getElementById("jog-down"); const jogUpBtn = document.getElementById("jog-up"); const jogStepEl = document.getElementById("jog-step"); +const jogMultEl = document.getElementById("jog-mult"); + +function applyJogStep() { + jogStep = Math.max(jogUnit * jogMult, minFreqStepHz); + saveSetting("jogUnit", jogUnit); + saveSetting("jogMult", jogMult); + saveSetting("jogStep", jogStep); + refreshFreqDisplay(); + refreshCenterFreqDisplay(); +} async function jogFreq(direction) { if (lastLocked) { showHint("Locked", 1500); return; } @@ -1716,29 +1732,51 @@ window.addEventListener("mouseup", () => { if (jogWheel) jogWheel.style.cursor = "grab"; }); -// Step selector +// Step unit selector jogStepEl.addEventListener("click", (e) => { const btn = e.target.closest("button[data-step]"); if (!btn) return; - jogStep = Math.max(parseInt(btn.dataset.step, 10), minFreqStepHz); + jogUnit = parseInt(btn.dataset.step, 10); jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); - saveSetting("jogStep", jogStep); - refreshFreqDisplay(); - refreshCenterFreqDisplay(); + applyJogStep(); }); -// Restore active jog step button from saved setting +// Step multiplier selector +if (jogMultEl) { + jogMultEl.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-mult]"); + if (!btn) return; + jogMult = parseInt(btn.dataset.mult, 10); + jogMultEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + applyJogStep(); + }); +} + +// Restore active jog step buttons from saved settings { - const buttons = Array.from(jogStepEl.querySelectorAll("button[data-step]")); - const active = - buttons.find((b) => parseInt(b.dataset.step, 10) === jogStep) || - buttons.find((b) => parseInt(b.dataset.step, 10) === 1000) || - buttons[0]; - if (active) { - jogStep = parseInt(active.dataset.step, 10); - buttons.forEach((b) => b.classList.toggle("active", b === active)); + const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]")); + const activeUnit = + unitBtns.find((b) => parseInt(b.dataset.step, 10) === jogUnit) || + unitBtns.find((b) => parseInt(b.dataset.step, 10) === 1000) || + unitBtns[0]; + if (activeUnit) { + jogUnit = parseInt(activeUnit.dataset.step, 10); + unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit)); } + if (jogMultEl) { + const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); + const activeMult = + multBtns.find((b) => parseInt(b.dataset.mult, 10) === jogMult) || + multBtns.find((b) => parseInt(b.dataset.mult, 10) === 1) || + multBtns[0]; + if (activeMult) { + jogMult = parseInt(activeMult.dataset.mult, 10); + multBtns.forEach((b) => b.classList.toggle("active", b === activeMult)); + } + } + jogStep = Math.max(jogUnit * jogMult, minFreqStepHz); } async function applyModeFromPicker() { @@ -2848,6 +2886,7 @@ function startSpectrumStreaming() { if (evt.data === "null") { lastSpectrumData = null; overviewWaterfallRows = []; + overviewWaterfallPushCount = 0; _wfResetOffscreen(); scheduleOverviewDraw(); clearSpectrumCanvas(); @@ -2883,6 +2922,7 @@ function stopSpectrumStreaming() { spectrumDrawPending = false; lastSpectrumData = null; overviewWaterfallRows = []; + overviewWaterfallPushCount = 0; _wfResetOffscreen(); scheduleOverviewDraw(); updateRdsPsOverlay(null); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index b9e92cf..46cafe9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -120,6 +120,14 @@
Unit
+
+
+ + + +
+
Mult
+
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 4d61d4f..58b66ec 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 @@ -343,6 +343,38 @@ button:disabled { opacity: 0.6; cursor: not-allowed; } display: flex; justify-content: flex-end; } +.mult-col { + flex: 0 0 auto; +} +.mult-col .label { + display: flex; + justify-content: flex-end; +} +.jog-mult { + display: flex; + border: 1px solid var(--border-light); + border-radius: 6px; + overflow: hidden; + height: 3.35rem; + flex-shrink: 0; +} +.jog-mult button { + border: none; + border-right: 1px solid var(--border-light); + border-radius: 0; + height: 100%; + padding: 0 0.5rem; + font-size: 0.85rem; + background: var(--input-bg); + color: var(--text-muted); + cursor: pointer; +} +.jog-mult button:last-child { border-right: none; } +.jog-mult button.active { + background: var(--btn-bg); + color: var(--accent-green); + font-weight: 600; +} .label-below-row { display: flex; flex-direction: column; diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs index febaed3..1ea9a1e 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs @@ -418,9 +418,25 @@ impl ChannelDsp { pub fn set_mode(&mut self, mode: &RigMode) { self.mode = mode.clone(); + self.audio_bandwidth_hz = default_bandwidth_for_mode(mode); self.demodulator = Demodulator::for_mode(mode); self.rebuild_filters(); } +} + +/// Returns the appropriate channel filter bandwidth for a given mode. +fn default_bandwidth_for_mode(mode: &RigMode) -> u32 { + match mode { + RigMode::LSB | RigMode::USB | RigMode::PKT | RigMode::DIG => 3_000, + RigMode::CW | RigMode::CWR => 500, + RigMode::AM => 6_000, + RigMode::FM => 12_500, + RigMode::WFM => 180_000, + RigMode::Other(_) => 3_000, + } +} + +impl ChannelDsp { /// Rebuild the FIR low-pass filters with new bandwidth and tap count. ///