[fix](trx-frontend-http): measure auto squelch from the meter
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:
@@ -4727,6 +4727,7 @@ function render(update) {
|
|||||||
const sUnits = dbmToSUnits(update.status.rx.sig);
|
const sUnits = dbmToSUnits(update.status.rx.sig);
|
||||||
sigLastSUnits = sUnits;
|
sigLastSUnits = sUnits;
|
||||||
sigLastDbm = update.status.rx.sig;
|
sigLastDbm = update.status.rx.sig;
|
||||||
|
recordSquelchMeterSample(update.status.rx.sig);
|
||||||
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
|
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
|
||||||
signalBar.style.width = `${pct}%`;
|
signalBar.style.width = `${pct}%`;
|
||||||
signalValue.innerHTML = formatSignal(sUnits);
|
signalValue.innerHTML = formatSignal(sUnits);
|
||||||
@@ -6369,12 +6370,28 @@ function setSdrSquelch(thresholdDb, enabled, options = {}) {
|
|||||||
renderSdrSquelch();
|
renderSdrSquelch();
|
||||||
if (options.submit !== false) submitSdrSquelch();
|
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() {
|
function autoSquelchThresholdDb() {
|
||||||
const data = lastSpectrumData || window.lastSpectrumData;
|
const noiseDb = squelchNoiseFloorDb();
|
||||||
if (!data || !isNumericBins(data.bins) || data.bins.length === 0) return null;
|
if (noiseDb == null) return null;
|
||||||
const noiseDb = estimateNoiseFloorDb(data.bins);
|
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
|
||||||
if (noiseDb == null || !isFiniteNumber(noiseDb)) return null;
|
|
||||||
return clampSdrSquelchDb(noiseDb + 6);
|
|
||||||
}
|
}
|
||||||
function updateSdrSquelchControlVisibility() {
|
function updateSdrSquelchControlVisibility() {
|
||||||
if (!sdrSquelchWrapEl) return;
|
if (!sdrSquelchWrapEl) return;
|
||||||
@@ -6407,17 +6424,30 @@ if (sdrSquelchToggleBtn) {
|
|||||||
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
|
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function applyAutoSquelch(threshold) {
|
||||||
|
setSdrSquelch(threshold, true);
|
||||||
|
showHint(`Squelch ${threshold} dB`, 1500);
|
||||||
|
}
|
||||||
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
|
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
|
||||||
if (sdrSquelchAutoBtn) {
|
if (sdrSquelchAutoBtn) {
|
||||||
sdrSquelchAutoBtn.addEventListener("click", () => {
|
sdrSquelchAutoBtn.addEventListener("click", () => {
|
||||||
if (!sdrSquelchSupported) return;
|
if (!sdrSquelchSupported) return;
|
||||||
const threshold = autoSquelchThresholdDb();
|
const threshold = autoSquelchThresholdDb();
|
||||||
if (threshold == null) {
|
if (threshold != null) {
|
||||||
showHint("No spectrum to measure the noise from", 1800);
|
applyAutoSquelch(threshold);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSdrSquelch(threshold, true);
|
sdrSquelchAutoBtn.disabled = true;
|
||||||
showHint(`Squelch ${threshold} dB`, 1500);
|
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) {
|
if (squelchGripEl) {
|
||||||
@@ -8067,6 +8097,7 @@ function flushMeterDom() {
|
|||||||
const sUnits = dbmToSUnits(dbm);
|
const sUnits = dbmToSUnits(dbm);
|
||||||
sigLastSUnits = sUnits;
|
sigLastSUnits = sUnits;
|
||||||
sigLastDbm = dbm;
|
sigLastDbm = dbm;
|
||||||
|
recordSquelchMeterSample(dbm);
|
||||||
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
|
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
|
||||||
if (signalBar) signalBar.style.width = `${pct}%`;
|
if (signalBar) signalBar.style.width = `${pct}%`;
|
||||||
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
|
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
|
||||||
|
|||||||
@@ -3647,6 +3647,7 @@ function render(update: AppUpdate) {
|
|||||||
const sUnits = dbmToSUnits(update.status.rx.sig);
|
const sUnits = dbmToSUnits(update.status.rx.sig);
|
||||||
sigLastSUnits = sUnits;
|
sigLastSUnits = sUnits;
|
||||||
sigLastDbm = update.status.rx.sig;
|
sigLastDbm = update.status.rx.sig;
|
||||||
|
recordSquelchMeterSample(update.status.rx.sig);
|
||||||
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
|
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
|
||||||
signalBar.style.width = `${pct}%`;
|
signalBar.style.width = `${pct}%`;
|
||||||
signalValue.innerHTML = formatSignal(sUnits);
|
signalValue.innerHTML = formatSignal(sUnits);
|
||||||
@@ -5343,13 +5344,45 @@ function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit?
|
|||||||
if (options.submit !== false) submitSdrSquelch();
|
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 {
|
function autoSquelchThresholdDb(): number | null {
|
||||||
const data = lastSpectrumData || window.lastSpectrumData;
|
const noiseDb = squelchNoiseFloorDb();
|
||||||
if (!data || !isBinsArray(data.bins) || data.bins.length === 0) return null;
|
if (noiseDb == null) return null;
|
||||||
const noiseDb = estimateNoiseFloorDb(data.bins);
|
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
|
||||||
if (noiseDb == null || !isFiniteNumber(noiseDb)) return null;
|
|
||||||
return clampSdrSquelchDb(noiseDb + 6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSdrSquelchControlVisibility() {
|
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;
|
const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto") as HTMLButtonElement | null;
|
||||||
if (sdrSquelchAutoBtn) {
|
if (sdrSquelchAutoBtn) {
|
||||||
sdrSquelchAutoBtn.addEventListener("click", () => {
|
sdrSquelchAutoBtn.addEventListener("click", () => {
|
||||||
if (!sdrSquelchSupported) return;
|
if (!sdrSquelchSupported) return;
|
||||||
const threshold = autoSquelchThresholdDb();
|
const threshold = autoSquelchThresholdDb();
|
||||||
if (threshold == null) {
|
if (threshold != null) {
|
||||||
showHint("No spectrum to measure the noise from", 1800);
|
applyAutoSquelch(threshold);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSdrSquelch(threshold, true);
|
// Nothing recorded yet — right after a connection or a rig switch. Listen
|
||||||
showHint(`Squelch ${threshold} dB`, 1500);
|
// 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);
|
const sUnits = dbmToSUnits(dbm);
|
||||||
sigLastSUnits = sUnits;
|
sigLastSUnits = sUnits;
|
||||||
sigLastDbm = dbm;
|
sigLastDbm = dbm;
|
||||||
|
recordSquelchMeterSample(dbm);
|
||||||
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
|
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
|
||||||
if (signalBar) signalBar.style.width = `${pct}%`;
|
if (signalBar) signalBar.style.width = `${pct}%`;
|
||||||
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
|
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
|
||||||
|
|||||||
@@ -35,11 +35,16 @@ const BANDPLAN = {
|
|||||||
const BAND_WITH_CONTENT = 7074000;
|
const BAND_WITH_CONTENT = 7074000;
|
||||||
const BAND_WITHOUT_CONTENT = 14074000;
|
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({
|
const fixture = await startWebFixture({
|
||||||
spectrum: true,
|
spectrum: true,
|
||||||
bookmarks: BOOKMARKS,
|
bookmarks: BOOKMARKS,
|
||||||
bandplan: BANDPLAN,
|
bandplan: BANDPLAN,
|
||||||
bandplanEnabled: true,
|
bandplanEnabled: true,
|
||||||
|
meterDb: METER_DB,
|
||||||
});
|
});
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
@@ -125,10 +130,14 @@ try {
|
|||||||
// row, with nothing on screen to relate the number to.
|
// row, with nothing on screen to relate the number to.
|
||||||
await page.locator("summary", { hasText: "Audio controls" }).click();
|
await page.locator("summary", { hasText: "Audio controls" }).click();
|
||||||
await page.locator("#sdr-squelch-toggle").click();
|
await page.locator("#sdr-squelch-toggle").click();
|
||||||
// Auto parks it just above the noise, which is mid-axis and leaves room to
|
// Auto parks it just above the noise the meter reports — not above the
|
||||||
// drag in either direction.
|
// 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.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 squelchOn = await page.evaluate(() => {
|
||||||
const line = document.getElementById("spectrum-squelch-line");
|
const line = document.getElementById("spectrum-squelch-line");
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function assetPath(urlPath) {
|
|||||||
export async function startWebFixture({
|
export async function startWebFixture({
|
||||||
spectrum = false,
|
spectrum = false,
|
||||||
tx = false,
|
tx = false,
|
||||||
|
meterDb = -70,
|
||||||
bookmarks = [],
|
bookmarks = [],
|
||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
@@ -111,7 +112,7 @@ export async function startWebFixture({
|
|||||||
signal_meter: spectrum,
|
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
|
// Reported only by SDR backends, and what makes the client show the
|
||||||
// squelch control at all.
|
// squelch control at all.
|
||||||
filter: spectrum
|
filter: spectrum
|
||||||
@@ -213,6 +214,20 @@ export async function startWebFixture({
|
|||||||
request.on("close", () => clearInterval(timer));
|
request.on("close", () => clearInterval(timer));
|
||||||
return;
|
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)) {
|
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
"cache-control": "no-cache",
|
"cache-control": "no-cache",
|
||||||
|
|||||||
Reference in New Issue
Block a user