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>
225 lines
10 KiB
JavaScript
225 lines
10 KiB
JavaScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
// Geometry of the spectrum area while tuning. Everything above the spectrum is
|
|
// driven by what happens to be in the visible range — band plan allocations,
|
|
// bookmarks — and the strips that show them used to appear and disappear with
|
|
// it, so tuning across a band edge moved the whole page under the operator's
|
|
// cursor. Nothing else in the suite serves a rig with a spectrum.
|
|
|
|
import assert from "node:assert/strict";
|
|
import { chromium } from "playwright-core";
|
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
|
|
/* global document, getComputedStyle */
|
|
|
|
const BOOKMARKS = [
|
|
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
|
|
{ id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" },
|
|
];
|
|
const BANDPLAN = {
|
|
iaru1: {
|
|
bands: [{
|
|
name: "40m",
|
|
low_hz: 7000000,
|
|
high_hz: 7200000,
|
|
segments: [
|
|
{ low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" },
|
|
{ low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" },
|
|
],
|
|
}],
|
|
},
|
|
};
|
|
// 40m has both bookmarks and allocations; 20m has neither.
|
|
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);
|
|
|
|
function readGeometry() {
|
|
const top = (selector) => {
|
|
const el = document.querySelector(selector);
|
|
return el ? Math.round(el.getBoundingClientRect().top) : null;
|
|
};
|
|
const axis = document.getElementById("spectrum-bookmark-axis");
|
|
const strip = document.getElementById("spectrum-bandplan-strip");
|
|
return {
|
|
chips: axis.querySelectorAll(".spectrum-bookmark-chip").length,
|
|
axisEmpty: axis.classList.contains("bm-axis-empty"),
|
|
stripReserved: strip.classList.contains("bp-visible"),
|
|
stripEmpty: strip.classList.contains("bp-empty"),
|
|
overviewTop: top(".overview-strip"),
|
|
spectrumTop: top("#spectrum-panel"),
|
|
controlsTop: top(".controls-row"),
|
|
footerTop: top(".footer"),
|
|
docHeight: document.documentElement.scrollHeight,
|
|
};
|
|
}
|
|
|
|
const layoutOf = (geometry) => ({
|
|
overviewTop: geometry.overviewTop,
|
|
spectrumTop: geometry.spectrumTop,
|
|
controlsTop: geometry.controlsTop,
|
|
footerTop: geometry.footerTop,
|
|
docHeight: geometry.docHeight,
|
|
});
|
|
|
|
async function tuneTo(hz) {
|
|
fixture.setCenterHz(hz);
|
|
await page.waitForTimeout(900);
|
|
return page.evaluate(readGeometry);
|
|
}
|
|
|
|
try {
|
|
await page.setViewportSize({ width: 1600, height: 950 });
|
|
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
|
|
await page.locator("#content").waitFor({ state: "visible" });
|
|
await page.locator("#spectrum-panel").waitFor({ state: "visible" });
|
|
await page.waitForTimeout(1500);
|
|
|
|
const populated = await tuneTo(BAND_WITH_CONTENT);
|
|
assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`);
|
|
assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it");
|
|
assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations");
|
|
assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it");
|
|
|
|
const bare = await tuneTo(BAND_WITHOUT_CONTENT);
|
|
assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`);
|
|
assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder");
|
|
assert.equal(bare.stripReserved, true, "band plan strip gave its height back");
|
|
assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty");
|
|
|
|
// The point of both placeholders: tuning must not move anything.
|
|
assert.deepEqual(layoutOf(bare), layoutOf(populated),
|
|
`tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`);
|
|
|
|
const back = await tuneTo(BAND_WITH_CONTENT);
|
|
assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back");
|
|
assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page");
|
|
|
|
// The rail covers the top of the overview, so only the chips may take
|
|
// pointer events — the rest has to fall through to the plot behind it.
|
|
const hits = await page.evaluate(() => {
|
|
const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip");
|
|
const chipRect = chip.getBoundingClientRect();
|
|
const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect();
|
|
const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2);
|
|
const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10);
|
|
return {
|
|
chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName),
|
|
besideChip: besideChip?.id || besideChip?.tagName,
|
|
};
|
|
});
|
|
assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`);
|
|
assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`);
|
|
|
|
// Squelch: the threshold is in the dB the spectrum axis is labelled in, so the
|
|
// line is the control. It used to be a percentage on a slider in the audio
|
|
// 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 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(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 {
|
|
shown: getComputedStyle(line).display !== "none",
|
|
db: Number(document.getElementById("sdr-squelch-db").value),
|
|
label: Number(document.getElementById("spectrum-squelch-label").textContent),
|
|
toggle: document.getElementById("sdr-squelch-toggle").textContent,
|
|
top: Math.round(line.getBoundingClientRect().top),
|
|
};
|
|
});
|
|
assert.equal(squelchOn.shown, true, "the threshold line did not appear with the squelch on");
|
|
assert.equal(squelchOn.toggle, "On", "the toggle did not follow the squelch state");
|
|
assert.equal(squelchOn.label, squelchOn.db, "the line and the readout disagree on the threshold");
|
|
|
|
// Dragging the line down lowers the threshold and tells the server.
|
|
const submitted = [];
|
|
page.on("request", (request) => {
|
|
if (request.url().includes("/set_sdr_squelch")) {
|
|
submitted.push(Number(new URL(request.url()).searchParams.get("threshold_db")));
|
|
}
|
|
});
|
|
const grip = await page.locator("#spectrum-squelch-grip").boundingBox();
|
|
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 60, { steps: 8 });
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(400);
|
|
const dragged = await page.evaluate(() => ({
|
|
db: Number(document.getElementById("sdr-squelch-db").value),
|
|
label: Number(document.getElementById("spectrum-squelch-label").textContent),
|
|
top: Math.round(document.getElementById("spectrum-squelch-line").getBoundingClientRect().top),
|
|
}));
|
|
assert.ok(dragged.db < squelchOn.db,
|
|
`dragging down left the threshold at ${dragged.db} dB (was ${squelchOn.db})`);
|
|
assert.equal(dragged.label, dragged.db, "the line label did not follow the drag");
|
|
assert.ok(dragged.top > squelchOn.top, "the line did not move with the drag");
|
|
assert.ok(submitted.includes(dragged.db),
|
|
`the server was never told about ${dragged.db} dB (saw ${JSON.stringify(submitted)})`);
|
|
|
|
// Turning it off leaves the threshold alone — the old control conflated the
|
|
// two, so dropping to zero to listen threw the setting away.
|
|
await page.locator("#sdr-squelch-toggle").click();
|
|
await page.waitForTimeout(300);
|
|
const squelchOff = await page.evaluate(() => ({
|
|
db: Number(document.getElementById("sdr-squelch-db").value),
|
|
shown: getComputedStyle(document.getElementById("spectrum-squelch-line")).display !== "none",
|
|
dot: document.getElementById("sdr-squelch-state").dataset.state,
|
|
}));
|
|
assert.equal(squelchOff.db, dragged.db, "turning the squelch off discarded the threshold");
|
|
assert.equal(squelchOff.shown, false, "the line stayed up with the squelch off");
|
|
assert.equal(squelchOff.dot, "off", "the indicator did not follow the squelch off");
|
|
|
|
assert.deepEqual(runtimeErrors, []);
|
|
} finally {
|
|
await browser.close();
|
|
await fixture.close();
|
|
}
|
|
|
|
// The band plan is fetched once at startup, which can land before the session
|
|
// exists. It used to fail silently and never retry, so the allocations only
|
|
// turned up if the operator reloaded the page by hand.
|
|
const retryFixture = await startWebFixture({
|
|
spectrum: true,
|
|
bandplan: BANDPLAN,
|
|
bandplanEnabled: true,
|
|
bandplanUnauthorizedFirst: true,
|
|
});
|
|
const retry = await startBrowser(chromium);
|
|
try {
|
|
await retry.page.setViewportSize({ width: 1600, height: 950 });
|
|
await retry.page.goto(retryFixture.origin, { waitUntil: "domcontentloaded" });
|
|
await retry.page.locator("#spectrum-panel").waitFor({ state: "visible" });
|
|
await retry.page.waitForTimeout(2000);
|
|
const strip = await retry.page.evaluate(() => {
|
|
const element = document.getElementById("spectrum-bandplan-strip");
|
|
return { segments: element.children.length, empty: element.classList.contains("bp-empty") };
|
|
});
|
|
assert.ok(strip.segments > 0,
|
|
"the band plan never arrived after its first request was refused");
|
|
assert.equal(strip.empty, false, "the strip is still showing its placeholder");
|
|
} finally {
|
|
await retry.browser.close();
|
|
await retryFixture.close();
|
|
}
|