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.

+ @@ -411,7 +415,13 @@ SPDX-License-Identifier: GPL-2.0-or-later - +
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") {