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 23604021..29b3e30b 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
@@ -2244,6 +2244,7 @@ function formatSigStrength(dbm) {
return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`;
}
function refreshSigStrengthDisplay() {
+ renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -6209,11 +6210,14 @@ var modeControlsRow = document.getElementById("mode-controls-row");
var samStereoWidthEl = document.getElementById("sam-stereo-width");
var samCarrierSyncEl = document.getElementById("sam-carrier-sync");
var sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
-var sdrSquelchEl = document.getElementById("sdr-squelch");
-var sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
+var sdrSquelchDbEl = document.getElementById("sdr-squelch-db");
+var sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
+var sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle");
+var squelchLineEl = document.getElementById("spectrum-squelch-line");
+var squelchGripEl = document.getElementById("spectrum-squelch-grip");
+var squelchLabelEl = document.getElementById("spectrum-squelch-label");
var SDR_SQUELCH_MIN_DB = -120;
var SDR_SQUELCH_MAX_DB = -30;
-var syncFromServerSdrSquelch = false;
var sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -6302,89 +6306,162 @@ function normalizeWfmDenoiseLevel(value) {
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto";
}
-function clampSdrSquelchPercent(value) {
- if (!isFiniteNumber(value)) return 0;
- return Math.max(0, Math.min(100, Math.round(value)));
+var sdrSquelchEnabled = loadSetting("sdrSquelchEnabled", false);
+var sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
+function clampSdrSquelchDb(value) {
+ if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
+ return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
}
-function sdrSquelchPercentToServer(percent) {
- const pct = clampSdrSquelchPercent(percent);
- if (pct <= 0) {
- return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
+function sdrSquelchIsPassing() {
+ if (!sdrSquelchEnabled) return true;
+ if (!isFiniteNumber(sigLastDbm)) return false;
+ return sigLastDbm >= sdrSquelchThresholdDb;
+}
+function renderSdrSquelch() {
+ if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
+ sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
}
- const ratio = pct / 100;
- const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
- return { enabled: true, thresholdDb };
+ if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
+ if (sdrSquelchToggleBtn) {
+ sdrSquelchToggleBtn.textContent = sdrSquelchEnabled ? "On" : "Off";
+ sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
+ }
+ const state = !sdrSquelchEnabled ? "off" : sdrSquelchIsPassing() ? "open" : "closed";
+ if (sdrSquelchStateEl) {
+ sdrSquelchStateEl.dataset.state = state;
+ sdrSquelchStateEl.setAttribute(
+ "aria-label",
+ state === "off" ? "Squelch off" : state === "open" ? "Squelch open" : "Squelch closed"
+ );
+ }
+ if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
+ if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
+ if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
+ positionSquelchLine();
}
-function sdrSquelchServerToPercent(enabled, thresholdDb) {
- if (!enabled) return 0;
- if (!isFiniteNumber(thresholdDb)) return 0;
- const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
- return clampSdrSquelchPercent(ratio * 100);
+function positionSquelchLine() {
+ if (!squelchLineEl) return;
+ const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
+ const canvas = document.getElementById("spectrum-canvas");
+ const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM" && !!canvas && canvas.clientHeight > 0 && getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
+ if (!visible) {
+ squelchLineEl.style.display = "none";
+ return;
+ }
+ const dbMin = spectrumFloor;
+ const dbMax = spectrumFloor + spectrumRange;
+ const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
+ squelchLineEl.style.display = "";
+ squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
}
-function updateSdrSquelchPctLabel() {
- if (!sdrSquelchEl || !sdrSquelchPctEl) return;
- const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
- sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
+function submitSdrSquelch() {
+ if (!sdrSquelchSupported) return;
+ saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
+ saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
+ postPath(
+ `/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`
+ ).catch(() => {
+ });
+}
+function setSdrSquelch(thresholdDb, enabled, options = {}) {
+ sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
+ sdrSquelchEnabled = enabled;
+ renderSdrSquelch();
+ if (options.submit !== false) submitSdrSquelch();
+}
+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);
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
+ renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled, thresholdDb) {
- if (!sdrSquelchEl) return;
- if (document.activeElement === sdrSquelchEl) return;
- const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
- syncFromServerSdrSquelch = true;
- sdrSquelchEl.value = String(pct);
- updateSdrSquelchPctLabel();
- syncFromServerSdrSquelch = false;
- saveSetting("sdrSquelchPct", pct);
+ if (squelchDragPointerId !== null) return;
+ if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
+ sdrSquelchEnabled = enabled;
+ if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
+ saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
+ saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
+ renderSdrSquelch();
}
-function submitSdrSquelchPercent(percent) {
- if (!sdrSquelchSupported) return;
- const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
- postPath(
- `/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`
- ).catch(() => {
+var squelchDragPointerId = null;
+var squelchDragSubmitAt = 0;
+if (sdrSquelchDbEl) {
+ sdrSquelchDbEl.addEventListener("change", () => {
+ setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
+ });
+ sdrSquelchDbEl.addEventListener("blur", () => {
+ renderSdrSquelch();
});
}
-if (sdrSquelchEl) {
- const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
- sdrSquelchEl.value = String(savedPct);
- updateSdrSquelchPctLabel();
- sdrSquelchEl.addEventListener("input", () => {
- const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
- sdrSquelchEl.value = String(pct);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", pct);
- if (!syncFromServerSdrSquelch) {
- submitSdrSquelchPercent(pct);
- }
+if (sdrSquelchToggleBtn) {
+ sdrSquelchToggleBtn.addEventListener("click", () => {
+ if (!sdrSquelchSupported) return;
+ setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
});
}
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
- let pct = 0;
- const data = lastSpectrumData || window.lastSpectrumData;
- if (data && isNumericBins(data.bins) && data.bins.length > 0) {
- const noiseDb = estimateNoiseFloorDb(data.bins);
- if (noiseDb != null && isFiniteNumber(noiseDb)) {
- const thresholdDb = noiseDb + 6;
- const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
- pct = clampSdrSquelchPercent(
- (clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
- );
- }
+ const threshold = autoSquelchThresholdDb();
+ if (threshold == null) {
+ showHint("No spectrum to measure the noise from", 1800);
+ return;
}
- if (sdrSquelchEl) {
- sdrSquelchEl.value = String(pct);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", pct);
+ setSdrSquelch(threshold, true);
+ showHint(`Squelch ${threshold} dB`, 1500);
+ });
+}
+if (squelchGripEl) {
+ const dbFromClientY = (clientY) => {
+ const canvas = document.getElementById("spectrum-canvas");
+ if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
+ const rect = canvas.getBoundingClientRect();
+ const frac = 1 - (clientY - rect.top) / rect.height;
+ return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
+ };
+ squelchGripEl.addEventListener("pointerdown", (event) => {
+ if (!sdrSquelchSupported) return;
+ squelchDragPointerId = event.pointerId;
+ squelchGripEl.setPointerCapture(event.pointerId);
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ squelchGripEl.addEventListener("pointermove", (event) => {
+ if (squelchDragPointerId !== event.pointerId) return;
+ sdrSquelchThresholdDb = dbFromClientY(event.clientY);
+ renderSdrSquelch();
+ const now = Date.now();
+ if (now - squelchDragSubmitAt > 200) {
+ squelchDragSubmitAt = now;
+ submitSdrSquelch();
}
- submitSdrSquelchPercent(pct);
+ });
+ const endDrag = (event) => {
+ if (squelchDragPointerId !== event.pointerId) return;
+ squelchDragPointerId = null;
+ submitSdrSquelch();
+ };
+ squelchGripEl.addEventListener("pointerup", endDrag);
+ squelchGripEl.addEventListener("pointercancel", endDrag);
+ squelchGripEl.addEventListener("keydown", (event) => {
+ const step = event.shiftKey ? 10 : 1;
+ if (event.key === "ArrowUp" || event.key === "ArrowRight") {
+ setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
+ } else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
+ setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
+ } else {
+ return;
+ }
+ event.preventDefault();
});
}
if (wfmAudioModeEl) {
@@ -7244,15 +7321,10 @@ function volWheel(slider, pctEl, getGain, storageKey) {
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
-if (sdrSquelchEl) {
- sdrSquelchEl.addEventListener("wheel", (e) => {
- e.preventDefault();
- const step = e.deltaY < 0 ? 2 : -2;
- const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
- sdrSquelchEl.value = String(next);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", next);
- submitSdrSquelchPercent(next);
+if (sdrSquelchDbEl) {
+ sdrSquelchDbEl.addEventListener("wheel", (event) => {
+ event.preventDefault();
+ setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
}, { passive: false });
}
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
@@ -8299,6 +8371,7 @@ function drawSpectrum(data) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
+ positionSquelchLine();
function hzToX(hz) {
return (hz - range.visLoHz) / range.visSpanHz * W;
}
@@ -8927,31 +9000,15 @@ window.addEventListener("keydown", (event) => {
}
if (key === "q") {
event.preventDefault();
- if (sdrSquelchSupported && sdrSquelchEl) {
- const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
- let nextPct;
- if (current > 0) {
- nextPct = 0;
+ if (sdrSquelchSupported) {
+ if (sdrSquelchEnabled) {
+ setSdrSquelch(sdrSquelchThresholdDb, false);
+ showHint("Squelch off", 1200);
} else {
- let auto = 30;
- const data = lastSpectrumData || window.lastSpectrumData;
- if (data && isNumericBins(data.bins) && data.bins.length > 0) {
- const noiseDb = estimateNoiseFloorDb(data.bins);
- if (noiseDb != null && isFiniteNumber(noiseDb)) {
- const thresholdDb = noiseDb + 6;
- const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
- auto = clampSdrSquelchPercent(
- (clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
- );
- }
- }
- nextPct = auto;
+ const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB ? sdrSquelchThresholdDb : autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25;
+ setSdrSquelch(threshold, true);
+ showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
}
- sdrSquelchEl.value = String(nextPct);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", nextPct);
- submitSdrSquelchPercent(nextPct);
- showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
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 ed043b60..6c3e9fe3 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
@@ -142,6 +142,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
Spectrum data is waiting for the receiver.
+
+ SQL -95 dB
+
@@ -411,7 +415,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
-
+
+ SQL
+
+
+
+
+
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 993a4a86..73caae17 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
@@ -1845,6 +1845,106 @@ small { color: var(--text-muted); }
color: var(--text-muted);
line-height: 1;
}
+/* Squelch: a threshold in dB, the unit the spectrum's own axis is labelled in,
+ with a dot showing whether the gate is passing audio right now. The old
+ percentage was a number with nothing on screen to relate it to. */
+.sql-control {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ white-space: nowrap;
+}
+.sql-title {
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+.sql-state {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: color-mix(in srgb, var(--text-muted) 45%, transparent);
+ transition: background var(--dur-fast) var(--ease-standard),
+ box-shadow var(--dur-fast) var(--ease-standard);
+}
+.sql-state[data-state="open"] {
+ background: var(--status-ok);
+ box-shadow: 0 0 0 0.14rem color-mix(in srgb, var(--status-ok) 25%, transparent);
+}
+.sql-state[data-state="closed"] {
+ background: var(--accent-yellow);
+ box-shadow: 0 0 0 0.14rem color-mix(in srgb, var(--accent-yellow) 22%, transparent);
+}
+.sql-db {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.2rem;
+}
+.sql-db input {
+ width: 3.4rem;
+ height: 1.5rem;
+ min-height: 0;
+ padding: 0 0.3rem;
+ font-size: 0.78rem;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ border: 1px solid var(--border-light);
+ border-radius: 4px;
+ background: var(--input-bg);
+ color: var(--text);
+}
+.sql-db-unit {
+ font-size: 0.68rem;
+}
+.sql-control button[aria-pressed="true"] {
+ border-color: color-mix(in srgb, var(--accent-green) 55%, var(--border-light));
+ color: var(--accent-text);
+}
+/* The line is the control: it spans the plot at its threshold and carries its
+ own grip, so the level is set where the noise it has to clear is visible. */
+.spectrum-squelch-line {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 0;
+ /* Above the overlays that share the plot: the split control sits at the
+ right edge on z-index 9 and would otherwise swallow the grip's pointer. */
+ z-index: 10;
+ border-top: 1px dashed color-mix(in srgb, var(--accent-yellow) 75%, transparent);
+ pointer-events: none;
+}
+.spectrum-squelch-line[data-state="open"] {
+ border-top-color: color-mix(in srgb, var(--status-ok) 80%, transparent);
+}
+.spectrum-squelch-grip {
+ position: absolute;
+ /* Clear of the split control's column so the two never sit on top of each
+ other, whatever height the threshold is at. */
+ right: 3.6rem;
+ top: -0.72rem;
+ padding: 0.05rem 0.35rem;
+ border-radius: 3px;
+ border: 1px solid color-mix(in srgb, var(--accent-yellow) 60%, var(--border-light));
+ background: color-mix(in srgb, var(--card-bg) 88%, transparent);
+ color: var(--text);
+ font-size: 0.62rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ line-height: 1.3;
+ cursor: ns-resize;
+ pointer-events: auto;
+ user-select: none;
+ touch-action: none;
+}
+.spectrum-squelch-line[data-state="open"] .spectrum-squelch-grip {
+ border-color: color-mix(in srgb, var(--status-ok) 60%, var(--border-light));
+}
+.spectrum-squelch-grip:focus-visible {
+ outline: 2px solid var(--accent-text);
+ outline-offset: 1px;
+}
.sql-auto-btn {
font-size: 0.68rem;
padding: 0 5px;
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 e565561d..6950595a 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
@@ -946,6 +946,8 @@ function formatSigStrength(dbm: number | null) {
}
function refreshSigStrengthDisplay() {
+ // The squelch indicator reads the same meter, so it follows it here.
+ renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -5151,11 +5153,14 @@ const modeControlsRow = document.getElementById("mode-controls-row");
const samStereoWidthEl = document.getElementById("sam-stereo-width") as HTMLInputElement | null;
const samCarrierSyncEl = document.getElementById("sam-carrier-sync") as HTMLSelectElement | null;
const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
-const sdrSquelchEl = document.getElementById("sdr-squelch") as HTMLInputElement | null;
-const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
+const sdrSquelchDbEl = document.getElementById("sdr-squelch-db") as HTMLInputElement | null;
+const sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
+const sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle") as HTMLButtonElement | null;
+const squelchLineEl = document.getElementById("spectrum-squelch-line");
+const squelchGripEl = document.getElementById("spectrum-squelch-grip");
+const squelchLabelEl = document.getElementById("spectrum-squelch-label");
const SDR_SQUELCH_MIN_DB = -120;
const SDR_SQUELCH_MAX_DB = -30;
-let syncFromServerSdrSquelch = false;
const sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputElement | null;
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -5255,71 +5260,131 @@ function normalizeWfmDenoiseLevel(value: unknown) {
return "auto";
}
-function clampSdrSquelchPercent(value: number) {
- if (!isFiniteNumber(value)) return 0;
- return Math.max(0, Math.min(100, Math.round(value)));
+// The threshold is held in dB, the scale the spectrum axis and the S-meter are
+// labelled in and the one the server compares against. It used to be a
+// percentage over that range, which left the operator no way to relate the
+// control to anything on screen — and 0% doubled as "disabled", so turning the
+// squelch off to listen threw the setting away.
+let sdrSquelchEnabled: boolean = loadSetting("sdrSquelchEnabled", false);
+let sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
+
+function clampSdrSquelchDb(value: number) {
+ if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
+ return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
}
-function sdrSquelchPercentToServer(percent: number) {
- const pct = clampSdrSquelchPercent(percent);
- if (pct <= 0) {
- return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
+/** Passing right now, as far as the meter can tell: the same comparison the
+ * DSP makes, against the same number it reports. */
+function sdrSquelchIsPassing() {
+ if (!sdrSquelchEnabled) return true;
+ if (!isFiniteNumber(sigLastDbm)) return false;
+ return sigLastDbm >= sdrSquelchThresholdDb;
+}
+
+function renderSdrSquelch() {
+ if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
+ sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
}
- const ratio = pct / 100;
- const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
- return { enabled: true, thresholdDb };
+ if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
+ if (sdrSquelchToggleBtn) {
+ sdrSquelchToggleBtn.textContent = sdrSquelchEnabled ? "On" : "Off";
+ sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
+ }
+ const state = !sdrSquelchEnabled ? "off" : (sdrSquelchIsPassing() ? "open" : "closed");
+ if (sdrSquelchStateEl) {
+ sdrSquelchStateEl.dataset.state = state;
+ sdrSquelchStateEl.setAttribute(
+ "aria-label",
+ state === "off" ? "Squelch off" : (state === "open" ? "Squelch open" : "Squelch closed"),
+ );
+ }
+ if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
+ if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
+ if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
+ positionSquelchLine();
}
-function sdrSquelchServerToPercent(enabled: boolean, thresholdDb: number | null) {
- if (!enabled) return 0;
- if (!isFiniteNumber(thresholdDb)) return 0;
- const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
- return clampSdrSquelchPercent(ratio * 100);
+/** Places the line at its threshold on the spectrum's dB axis. */
+function positionSquelchLine() {
+ if (!squelchLineEl) return;
+ const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
+ const canvas = document.getElementById("spectrum-canvas");
+ const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM"
+ && !!canvas && canvas.clientHeight > 0
+ && getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
+ if (!visible) {
+ squelchLineEl.style.display = "none";
+ return;
+ }
+ const dbMin = spectrumFloor;
+ const dbMax = spectrumFloor + spectrumRange;
+ // Pinned to the plot edge when the threshold sits outside the visible dB
+ // window rather than hidden: the grip is how it gets dragged back, and the
+ // label still reads the real value.
+ const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
+ squelchLineEl.style.display = "";
+ squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
}
-function updateSdrSquelchPctLabel() {
- if (!sdrSquelchEl || !sdrSquelchPctEl) return;
- const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
- sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
+function submitSdrSquelch() {
+ if (!sdrSquelchSupported) return;
+ saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
+ saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
+ postPath(
+ `/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}`
+ + `&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`,
+ ).catch(() => {});
+}
+
+function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit?: boolean } = {}) {
+ sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
+ sdrSquelchEnabled = enabled;
+ renderSdrSquelch();
+ if (options.submit !== false) submitSdrSquelch();
+}
+
+/** Just above the noise, which is the level the gate has to clear. */
+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);
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
+ renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled: boolean, thresholdDb: number | null) {
- if (!sdrSquelchEl) return;
- if (document.activeElement === sdrSquelchEl) return;
- const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
- syncFromServerSdrSquelch = true;
- sdrSquelchEl.value = String(pct);
- updateSdrSquelchPctLabel();
- syncFromServerSdrSquelch = false;
- saveSetting("sdrSquelchPct", pct);
+ // Not while the operator is on the control: dragging the line or typing a
+ // level would fight the echo of the value the server last confirmed.
+ if (squelchDragPointerId !== null) return;
+ if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
+ sdrSquelchEnabled = enabled;
+ if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
+ saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
+ saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
+ renderSdrSquelch();
}
-function submitSdrSquelchPercent(percent: number) {
- if (!sdrSquelchSupported) return;
- const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
- postPath(
- `/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`,
- ).catch(() => {});
+let squelchDragPointerId: number | null = null;
+let squelchDragSubmitAt = 0;
+
+if (sdrSquelchDbEl) {
+ sdrSquelchDbEl.addEventListener("change", () => {
+ setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
+ });
+ sdrSquelchDbEl.addEventListener("blur", () => { renderSdrSquelch(); });
}
-if (sdrSquelchEl) {
- const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
- sdrSquelchEl.value = String(savedPct);
- updateSdrSquelchPctLabel();
- sdrSquelchEl.addEventListener("input", () => {
- const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
- sdrSquelchEl.value = String(pct);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", pct);
- if (!syncFromServerSdrSquelch) {
- submitSdrSquelchPercent(pct);
- }
+if (sdrSquelchToggleBtn) {
+ sdrSquelchToggleBtn.addEventListener("click", () => {
+ if (!sdrSquelchSupported) return;
+ setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
});
}
@@ -5327,25 +5392,60 @@ const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto") as HTMLBut
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
- let pct = 0; // default: Off
- const data = lastSpectrumData || window.lastSpectrumData;
- if (data && isBinsArray(data.bins) && data.bins.length > 0) {
- const noiseDb = estimateNoiseFloorDb(data.bins);
- if (noiseDb != null && isFiniteNumber(noiseDb)) {
- // Set threshold slightly above noise floor so squelch closes on noise
- const thresholdDb = noiseDb + 6;
- const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
- pct = clampSdrSquelchPercent(
- ((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
- );
- }
+ const threshold = autoSquelchThresholdDb();
+ if (threshold == null) {
+ showHint("No spectrum to measure the noise from", 1800);
+ return;
}
- if (sdrSquelchEl) {
- sdrSquelchEl.value = String(pct);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", pct);
+ setSdrSquelch(threshold, true);
+ showHint(`Squelch ${threshold} dB`, 1500);
+ });
+}
+
+// Dragging the line, the same gesture as the bandwidth edges. Submits are
+// throttled so the gate follows the drag by ear without a request per pixel.
+if (squelchGripEl) {
+ const dbFromClientY = (clientY: number) => {
+ const canvas = document.getElementById("spectrum-canvas");
+ if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
+ const rect = canvas.getBoundingClientRect();
+ const frac = 1 - (clientY - rect.top) / rect.height;
+ return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
+ };
+ squelchGripEl.addEventListener("pointerdown", (event) => {
+ if (!sdrSquelchSupported) return;
+ squelchDragPointerId = event.pointerId;
+ squelchGripEl.setPointerCapture(event.pointerId);
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ squelchGripEl.addEventListener("pointermove", (event) => {
+ if (squelchDragPointerId !== event.pointerId) return;
+ sdrSquelchThresholdDb = dbFromClientY(event.clientY);
+ renderSdrSquelch();
+ const now = Date.now();
+ if (now - squelchDragSubmitAt > 200) {
+ squelchDragSubmitAt = now;
+ submitSdrSquelch();
}
- submitSdrSquelchPercent(pct);
+ });
+ const endDrag = (event: PointerEvent) => {
+ if (squelchDragPointerId !== event.pointerId) return;
+ squelchDragPointerId = null;
+ submitSdrSquelch();
+ };
+ squelchGripEl.addEventListener("pointerup", endDrag);
+ squelchGripEl.addEventListener("pointercancel", endDrag);
+ squelchGripEl.addEventListener("keydown", (event) => {
+ const step = event.shiftKey ? 10 : 1;
+ if (event.key === "ArrowUp" || event.key === "ArrowRight") {
+ setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
+ } else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
+ setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
+ } else {
+ return;
+ }
+ event.preventDefault();
});
}
@@ -6176,15 +6276,10 @@ function volWheel(slider: HTMLInputElement, pctEl: HTMLElement, getGain: () => G
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
-if (sdrSquelchEl) {
- sdrSquelchEl.addEventListener("wheel", (e) => {
- e.preventDefault();
- const step = e.deltaY < 0 ? 2 : -2;
- const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
- sdrSquelchEl.value = String(next);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", next);
- submitSdrSquelchPercent(next);
+if (sdrSquelchDbEl) {
+ sdrSquelchDbEl.addEventListener("wheel", (event) => {
+ event.preventDefault();
+ setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
}, { passive: false });
}
@@ -7398,6 +7493,9 @@ function drawSpectrum(data: SpectrumFrame) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
+ // The squelch line rides the same axis: reposition it whenever the axis or
+ // the plot geometry moves under it.
+ positionSquelchLine();
function hzToX(hz: number) {
return ((hz - range.visLoHz) / range.visSpanHz) * W;
@@ -8125,35 +8223,21 @@ window.addEventListener("keydown", (event) => {
return;
}
- // Q — toggle squelch (cycle 0 → auto → 0)
+ // Q — gate on or off, keeping the threshold. Picks one off the noise floor
+ // the first time, when there is nothing to keep.
if (key === "q") {
event.preventDefault();
- if (sdrSquelchSupported && sdrSquelchEl) {
- const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
- let nextPct;
- if (current > 0) {
- nextPct = 0; // turn off
+ if (sdrSquelchSupported) {
+ if (sdrSquelchEnabled) {
+ setSdrSquelch(sdrSquelchThresholdDb, false);
+ showHint("Squelch off", 1200);
} else {
- // Auto: estimate from noise floor
- let auto = 30;
- const data = lastSpectrumData || window.lastSpectrumData;
- if (data && isBinsArray(data.bins) && data.bins.length > 0) {
- const noiseDb = estimateNoiseFloorDb(data.bins);
- if (noiseDb != null && isFiniteNumber(noiseDb)) {
- const thresholdDb = noiseDb + 6;
- const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
- auto = clampSdrSquelchPercent(
- ((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
- );
- }
- }
- nextPct = auto;
+ const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB
+ ? sdrSquelchThresholdDb
+ : (autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25);
+ setSdrSquelch(threshold, true);
+ showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
}
- sdrSquelchEl.value = String(nextPct);
- updateSdrSquelchPctLabel();
- saveSetting("sdrSquelchPct", nextPct);
- submitSdrSquelchPercent(nextPct);
- showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
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 de6a39a1..a1c0a1d3 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
@@ -12,7 +12,7 @@ import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
-/* global document */
+/* global document, getComputedStyle */
const BOOKMARKS = [
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
@@ -120,6 +120,67 @@ try {
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, which is mid-axis and leaves room to
+ // drag in either direction.
+ await page.locator("#sdr-squelch-auto").click();
+ await page.waitForTimeout(300);
+ 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();
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 276ac0cd..5afeccf3 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
@@ -111,7 +111,17 @@ export async function startWebFixture({
signal_meter: spectrum,
},
},
- status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
+ status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: -70 }, lock: null },
+ // Reported only by SDR backends, and what makes the client show the
+ // squelch control at all.
+ filter: spectrum
+ ? {
+ bandwidth_hz: 12_000,
+ sdr_squelch_enabled: false,
+ sdr_squelch_threshold_db: -95,
+ sdr_agc_enabled: false,
+ }
+ : null,
band: null,
enabled: true,
initialized: true,
@@ -184,8 +194,11 @@ export async function startWebFixture({
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
+ // 200 means "audio is configured": the client hides the whole audio row —
+ // and the squelch control with it — when this 404s.
if (url.pathname === "/audio") {
- response.writeHead(404).end();
+ response.writeHead(200, { "content-type": "application/json" });
+ response.end(JSON.stringify({ sample_rate: 48_000, channels: 1 }));
return;
}
if (spectrum && url.pathname === "/spectrum") {