Compare commits
2
Commits
d68a84f7f9
...
aefd36c4b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aefd36c4b1 | ||
|
|
25b9c31c9b |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
||||
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
|
||||
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
|
||||
<div id="spectrum-squelch-line" class="spectrum-squelch-line" style="display:none;" data-state="closed">
|
||||
<span class="spectrum-squelch-grip" id="spectrum-squelch-grip" role="slider" tabindex="0"
|
||||
aria-label="Squelch threshold" aria-valuemin="-120" aria-valuemax="-30" aria-valuenow="-95">SQL <span id="spectrum-squelch-label">-95</span> dB</span>
|
||||
</div>
|
||||
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
||||
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
||||
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
||||
@@ -411,7 +415,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<button id="tx-audio-btn" type="button">Transmit Audio</button>
|
||||
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
|
||||
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
|
||||
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
|
||||
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
|
||||
<span class="sql-title">SQL</span>
|
||||
<span class="sql-state" id="sdr-squelch-state" data-state="off" role="img" aria-label="Squelch state"></span>
|
||||
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
|
||||
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
|
||||
<button id="sdr-squelch-toggle" type="button" class="sql-auto-btn" aria-pressed="false" title="Enable or disable the squelch">Off</button>
|
||||
</span>
|
||||
<div id="audio-level">
|
||||
<div id="audio-level-fill"></div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -837,12 +837,6 @@ impl ChannelDsp {
|
||||
}
|
||||
}
|
||||
|
||||
let signal_power = decimated
|
||||
.iter()
|
||||
.map(|s| s.re * s.re + s.im * s.im)
|
||||
.sum::<f32>()
|
||||
/ decimated.len() as f32;
|
||||
let signal_db = 10.0 * signal_power.max(1e-12).log10();
|
||||
const WFM_OUTPUT_GAIN: f32 = 0.50;
|
||||
let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
|
||||
let mut out = decoder.process_iq(decimated);
|
||||
@@ -884,7 +878,14 @@ impl ChannelDsp {
|
||||
raw
|
||||
}
|
||||
};
|
||||
if !self.squelch.update(&self.mode, signal_db) {
|
||||
// Against the meter reading, not the block level after the IQ AGC.
|
||||
// The threshold arrives in the scale the operator sets it from — the
|
||||
// S-meter and the spectrum — while the level measured here had been
|
||||
// through the AGC, whose whole job is to hold it at a setpoint. For
|
||||
// every mode that has one (FM, PKT, AIS, AM, SAM) the squelch was
|
||||
// therefore comparing against a near-constant, and for the rest it was
|
||||
// still off by the decimation correction the meter applies.
|
||||
if !self.squelch.update(&self.mode, self.last_signal_db) {
|
||||
audio.fill(0.0);
|
||||
}
|
||||
|
||||
@@ -933,6 +934,93 @@ mod tests {
|
||||
dsp.process_block(&block);
|
||||
}
|
||||
|
||||
/// Feeds one signal twice, with the squelch threshold set from the channel's
|
||||
/// own meter reading: 6 dB above it must gate the audio, 6 dB below it must
|
||||
/// pass. FM runs an IQ AGC, so a squelch measured after that stage compares
|
||||
/// against a level pinned near the AGC setpoint — some 20 dB adrift of the
|
||||
/// scale the operator reads the threshold off, and open on plain noise.
|
||||
#[test]
|
||||
fn squelch_follows_the_meter_the_threshold_is_set_from() {
|
||||
const AMPLITUDE: f32 = 0.0025;
|
||||
|
||||
let (pcm_tx, mut pcm_rx) = broadcast::channel::<Vec<f32>>(4096);
|
||||
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(8);
|
||||
let mut dsp = ChannelDsp::new(
|
||||
0.0,
|
||||
&RigMode::FM,
|
||||
48_000,
|
||||
8_000,
|
||||
1,
|
||||
20,
|
||||
12_000,
|
||||
75,
|
||||
true,
|
||||
false,
|
||||
VirtualSquelchConfig::default(),
|
||||
NoiseBlankerConfig::default(),
|
||||
pcm_tx,
|
||||
iq_tx,
|
||||
);
|
||||
|
||||
// A 1 kHz tone on the carrier, so an open gate is audibly non-zero and
|
||||
// a closed one is unambiguously silent.
|
||||
let mut phase = 0.0_f32;
|
||||
let mut mod_phase = 0.0_f32;
|
||||
let mut feed = |dsp: &mut ChannelDsp, blocks: usize| {
|
||||
for _ in 0..blocks {
|
||||
let mut block = Vec::with_capacity(4096);
|
||||
for _ in 0..4096 {
|
||||
mod_phase += std::f32::consts::TAU * 1_000.0 / 48_000.0;
|
||||
phase += std::f32::consts::TAU * (3_000.0 * mod_phase.sin()) / 48_000.0;
|
||||
block.push(Complex::new(
|
||||
AMPLITUDE * phase.cos(),
|
||||
AMPLITUDE * phase.sin(),
|
||||
));
|
||||
}
|
||||
dsp.process_block(&block);
|
||||
}
|
||||
};
|
||||
let drain = |rx: &mut broadcast::Receiver<Vec<f32>>| {
|
||||
let mut audio = Vec::new();
|
||||
while let Ok(frame) = rx.try_recv() {
|
||||
audio.extend_from_slice(&frame);
|
||||
}
|
||||
audio
|
||||
};
|
||||
let peak = |audio: &[f32]| audio.iter().fold(0.0_f32, |acc, s| acc.max(s.abs()));
|
||||
|
||||
// Settle the meter on this signal, then read what the operator would.
|
||||
feed(&mut dsp, 24);
|
||||
let meter_db = dsp.signal_db();
|
||||
assert!(
|
||||
meter_db > -120.0,
|
||||
"the meter never moved off its floor ({meter_db} dB)"
|
||||
);
|
||||
|
||||
dsp.set_squelch(true, meter_db + 6.0);
|
||||
let _ = drain(&mut pcm_rx);
|
||||
feed(&mut dsp, 24);
|
||||
let gated = drain(&mut pcm_rx);
|
||||
assert!(!gated.is_empty(), "no audio frames were produced at all");
|
||||
// From the second half on: the first frame out still carries the audio
|
||||
// that was already buffered when the threshold changed.
|
||||
assert_eq!(
|
||||
peak(&gated[gated.len() / 2..]),
|
||||
0.0,
|
||||
"squelch set 6 dB above the meter ({meter_db} dB) still passed audio"
|
||||
);
|
||||
|
||||
dsp.set_squelch(true, meter_db - 6.0);
|
||||
let _ = drain(&mut pcm_rx);
|
||||
feed(&mut dsp, 24);
|
||||
let passed = drain(&mut pcm_rx);
|
||||
assert!(!passed.is_empty(), "no audio frames were produced at all");
|
||||
assert!(
|
||||
peak(&passed[passed.len() / 2..]) > 0.0,
|
||||
"squelch set 6 dB below the meter ({meter_db} dB) gated the audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_dsp_set_mode() {
|
||||
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);
|
||||
|
||||
Reference in New Issue
Block a user