From 2c1df75d19f0e173460007d604fe15c1f205693c Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Mon, 3 Aug 2026 23:32:18 +0200 Subject: [PATCH] [fix](trx-frontend-http): measure auto squelch from the meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto took the spectrum's noise floor and added 6 dB, but the threshold is compared against the channel level the meter reports, and the two sit a long way apart: the gap is set by the FFT size and window, the channel bandwidth, the decimation, and peak-versus-mean statistics. Measured on white noise it runs +22.1 dB at 48k/8k/3k, +18.7 dB at 240k/24k/12k and -1.2 dB at 1.92M/24k/12k — a 23 dB swing across ordinary configurations. Only the last of those is anywhere near right, so on a narrow span Auto set the gate some 20 dB below the noise and it never closed. It now reads the same number the DSP compares: the 20th percentile of the meter over the last ten seconds, plus 5 dB. The percentile keeps a burst of traffic inside the window from dragging the estimate up, and 5 dB clears the meter's own jitter, which measured 0.9-1.6 dB. Nothing in it converts between scales, so no part of the signal chain can put it out again. With no history yet — a fresh connection, a rig switch — it listens for a moment rather than refusing. The fixture gained a streaming /meter, without which there is nothing to measure, and the spectrum test pins auto to the meter it serves. Signed-off-by: Stan Grams --- .../assets/web/generated/app.js | 49 ++++++++++--- .../trx-frontend-http/frontend/src/app.ts | 70 ++++++++++++++++--- .../frontend/tests/spectrum-layout.mjs | 15 +++- .../frontend/tests/web-fixture.mjs | 17 ++++- 4 files changed, 128 insertions(+), 23 deletions(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 29b3e30b..b8d2a20e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -4727,6 +4727,7 @@ function render(update) { const sUnits = dbmToSUnits(update.status.rx.sig); sigLastSUnits = sUnits; sigLastDbm = update.status.rx.sig; + recordSquelchMeterSample(update.status.rx.sig); const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100; signalBar.style.width = `${pct}%`; signalValue.innerHTML = formatSignal(sUnits); @@ -6369,12 +6370,28 @@ function setSdrSquelch(thresholdDb, enabled, options = {}) { renderSdrSquelch(); if (options.submit !== false) submitSdrSquelch(); } +var SQUELCH_NOISE_WINDOW_MS = 1e4; +var SQUELCH_NOISE_MARGIN_DB = 5; +var SQUELCH_MEASURE_MS = 1500; +var squelchMeterSamples = []; +function recordSquelchMeterSample(db) { + if (!isFiniteNumber(db)) return; + const now = Date.now(); + squelchMeterSamples.push({ t: now, v: db }); + while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) { + squelchMeterSamples.shift(); + } +} +function squelchNoiseFloorDb() { + const now = Date.now(); + const values = squelchMeterSamples.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS).map((sample) => sample.v).sort((a, b) => a - b); + if (values.length < 4) return null; + return values[Math.floor((values.length - 1) * 0.2)] ?? null; +} function autoSquelchThresholdDb() { - const data = lastSpectrumData || window.lastSpectrumData; - if (!data || !isNumericBins(data.bins) || data.bins.length === 0) return null; - const noiseDb = estimateNoiseFloorDb(data.bins); - if (noiseDb == null || !isFiniteNumber(noiseDb)) return null; - return clampSdrSquelchDb(noiseDb + 6); + const noiseDb = squelchNoiseFloorDb(); + if (noiseDb == null) return null; + return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB); } function updateSdrSquelchControlVisibility() { if (!sdrSquelchWrapEl) return; @@ -6407,17 +6424,30 @@ if (sdrSquelchToggleBtn) { setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled); }); } +function applyAutoSquelch(threshold) { + setSdrSquelch(threshold, true); + showHint(`Squelch ${threshold} dB`, 1500); +} var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto"); if (sdrSquelchAutoBtn) { sdrSquelchAutoBtn.addEventListener("click", () => { if (!sdrSquelchSupported) return; const threshold = autoSquelchThresholdDb(); - if (threshold == null) { - showHint("No spectrum to measure the noise from", 1800); + if (threshold != null) { + applyAutoSquelch(threshold); return; } - setSdrSquelch(threshold, true); - showHint(`Squelch ${threshold} dB`, 1500); + sdrSquelchAutoBtn.disabled = true; + showHint("Measuring the noise…"); + setTimeout(() => { + sdrSquelchAutoBtn.disabled = false; + const measured = autoSquelchThresholdDb(); + if (measured == null) { + showHint("No meter to measure the noise from", 1800); + return; + } + applyAutoSquelch(measured); + }, SQUELCH_MEASURE_MS); }); } if (squelchGripEl) { @@ -8067,6 +8097,7 @@ function flushMeterDom() { const sUnits = dbmToSUnits(dbm); sigLastSUnits = sUnits; sigLastDbm = dbm; + recordSquelchMeterSample(dbm); const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100; if (signalBar) signalBar.style.width = `${pct}%`; if (signalValue) signalValue.innerHTML = formatSignal(sUnits); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 6950595a..b9624c51 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -3647,6 +3647,7 @@ function render(update: AppUpdate) { const sUnits = dbmToSUnits(update.status.rx.sig); sigLastSUnits = sUnits; sigLastDbm = update.status.rx.sig; + recordSquelchMeterSample(update.status.rx.sig); const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100; signalBar.style.width = `${pct}%`; signalValue.innerHTML = formatSignal(sUnits); @@ -5343,13 +5344,45 @@ function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit? if (options.submit !== false) submitSdrSquelch(); } -/** Just above the noise, which is the level the gate has to clear. */ +// Auto reads the meter, not the spectrum. The threshold is compared against +// the channel level the meter reports, and that sits a long way from the +// spectrum's per-bin noise floor — the gap is set by the FFT size and window, +// the channel bandwidth, the decimation and peak-versus-mean statistics. +// Measured across ordinary configurations it ranges from -1 dB to +22 dB, so +// the old "noise floor + 6 dB" left the gate 16-22 dB below the noise on a +// narrow span and it simply never closed. Reading the same number the DSP +// compares needs no conversion at all. +const SQUELCH_NOISE_WINDOW_MS = 10_000; +const SQUELCH_NOISE_MARGIN_DB = 5; +const SQUELCH_MEASURE_MS = 1_500; +const squelchMeterSamples: SignalSample[] = []; + +function recordSquelchMeterSample(db: number) { + if (!isFiniteNumber(db)) return; + const now = Date.now(); + squelchMeterSamples.push({ t: now, v: db }); + while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) { + squelchMeterSamples.shift(); + } +} + +/** The level the channel rests at, taken low enough down the distribution that + * a burst of traffic inside the window cannot drag it up. */ +function squelchNoiseFloorDb(): number | null { + const now = Date.now(); + const values = squelchMeterSamples + .filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS) + .map((sample) => sample.v) + .sort((a, b) => a - b); + if (values.length < 4) return null; + return values[Math.floor((values.length - 1) * 0.2)] ?? null; +} + +/** Just clear of the noise the meter is actually reading. */ function autoSquelchThresholdDb(): number | null { - const data = lastSpectrumData || window.lastSpectrumData; - if (!data || !isBinsArray(data.bins) || data.bins.length === 0) return null; - const noiseDb = estimateNoiseFloorDb(data.bins); - if (noiseDb == null || !isFiniteNumber(noiseDb)) return null; - return clampSdrSquelchDb(noiseDb + 6); + const noiseDb = squelchNoiseFloorDb(); + if (noiseDb == null) return null; + return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB); } function updateSdrSquelchControlVisibility() { @@ -5388,17 +5421,33 @@ if (sdrSquelchToggleBtn) { }); } +function applyAutoSquelch(threshold: number) { + setSdrSquelch(threshold, true); + showHint(`Squelch ${threshold} dB`, 1500); +} + const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto") as HTMLButtonElement | null; if (sdrSquelchAutoBtn) { sdrSquelchAutoBtn.addEventListener("click", () => { if (!sdrSquelchSupported) return; const threshold = autoSquelchThresholdDb(); - if (threshold == null) { - showHint("No spectrum to measure the noise from", 1800); + if (threshold != null) { + applyAutoSquelch(threshold); return; } - setSdrSquelch(threshold, true); - showHint(`Squelch ${threshold} dB`, 1500); + // Nothing recorded yet — right after a connection or a rig switch. Listen + // for a moment rather than refusing, which is what a radio does. + sdrSquelchAutoBtn.disabled = true; + showHint("Measuring the noise…"); + setTimeout(() => { + sdrSquelchAutoBtn.disabled = false; + const measured = autoSquelchThresholdDb(); + if (measured == null) { + showHint("No meter to measure the noise from", 1800); + return; + } + applyAutoSquelch(measured); + }, SQUELCH_MEASURE_MS); }); } @@ -7147,6 +7196,7 @@ function flushMeterDom() { const sUnits = dbmToSUnits(dbm); sigLastSUnits = sUnits; sigLastDbm = dbm; + recordSquelchMeterSample(dbm); const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100; if (signalBar) signalBar.style.width = `${pct}%`; if (signalValue) signalValue.innerHTML = formatSignal(sUnits); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs index a1c0a1d3..c086bac8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs @@ -35,11 +35,16 @@ const BANDPLAN = { const BAND_WITH_CONTENT = 7074000; const BAND_WITHOUT_CONTENT = 14074000; +// The meter sits well away from the spectrum's noise floor, so a squelch that +// took its level from the plot would land somewhere else entirely. +const METER_DB = -70; + const fixture = await startWebFixture({ spectrum: true, bookmarks: BOOKMARKS, bandplan: BANDPLAN, bandplanEnabled: true, + meterDb: METER_DB, }); const { browser, page, runtimeErrors } = await startBrowser(chromium); @@ -125,10 +130,14 @@ try { // row, with nothing on screen to relate the number to. await page.locator("summary", { hasText: "Audio controls" }).click(); await page.locator("#sdr-squelch-toggle").click(); - // Auto parks it just above the noise, which is mid-axis and leaves room to - // drag in either direction. + // Auto parks it just above the noise the meter reports — not above the + // spectrum's noise floor, which sits anywhere from 1 dB below to 22 dB above + // the meter depending on span, bandwidth and decimation. await page.locator("#sdr-squelch-auto").click(); - await page.waitForTimeout(300); + await page.waitForTimeout(400); + const auto = await page.evaluate(() => Number(document.getElementById("sdr-squelch-db").value)); + assert.ok(Math.abs(auto - (METER_DB + 5)) <= 1, + `auto put the threshold at ${auto} dB with the meter at ${METER_DB} dB`); const squelchOn = await page.evaluate(() => { const line = document.getElementById("spectrum-squelch-line"); return { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs index 5afeccf3..5568d0f8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs @@ -64,6 +64,7 @@ function assetPath(urlPath) { export async function startWebFixture({ spectrum = false, tx = false, + meterDb = -70, bookmarks = [], bandplan = {}, bandplanEnabled = false, @@ -111,7 +112,7 @@ export async function startWebFixture({ signal_meter: spectrum, }, }, - status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: -70 }, lock: null }, + status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null }, // Reported only by SDR backends, and what makes the client show the // squelch control at all. filter: spectrum @@ -213,6 +214,20 @@ export async function startWebFixture({ request.on("close", () => clearInterval(timer)); return; } + // The meter streams like the server's does: the squelch reads its noise + // level from here, so a static snapshot would leave it nothing to measure. + if (url.pathname === "/meter") { + response.writeHead(200, { + "cache-control": "no-cache", + connection: "keep-alive", + "content-type": "text/event-stream", + }); + const timer = setInterval(() => { + response.write(`data: ${JSON.stringify({ sig: meterDb })}\n\n`); + }, 120); + request.on("close", () => clearInterval(timer)); + return; + } if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) { response.writeHead(200, { "cache-control": "no-cache",