[fix](trx-frontend-http): measure auto squelch from the meter
CI / frontend (push) Successful in 3m16s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m14s

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 <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-03 23:32:18 +02:00
parent aefd36c4b1
commit 2c1df75d19
4 changed files with 128 additions and 23 deletions
@@ -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);
@@ -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);
@@ -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 {
@@ -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",