Compare commits

...
2 Commits
Author SHA1 Message Date
sjg aefd36c4b1 [feat](trx-frontend-http): set the squelch on the spectrum, in dB
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m41s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
The threshold is in dB, and since the squelch fix that is the scale the
spectrum axis and the S-meter are labelled in — so the control belongs
on the plot, at the level it gates.  A dashed line spans the spectrum at
its threshold with a grip that reads it out, dragged like the bandwidth
edges, green while the signal is above it and amber while it gates.
Arrow keys move it a dB at a time for anyone not using a mouse.

The audio row keeps a compact version: the dB, an indicator lit from the
same meter the DSP compares against, Auto, and an enable toggle that no
longer doubles as the level.  The slider ran 0-100% over that dB range,
which gave the operator a number with nothing on screen to relate it to,
and zero meant "disabled", so turning the squelch off to listen threw
the threshold away.  Auto now says which level it picked.

Two things the browser could only show once it was on the plot: the grip
landed underneath the split control at the right edge, which swallowed
its pointer, and dragging to the foot of the axis hid the line — and the
grip with it — instead of pinning it where it could be dragged back.

The fixture could not exercise any of this: /audio answered 404, which
hides the audio row and the control inside it, and the status carried no
filter block, which is what tells the client the rig has a squelch at
all.  Both now look like an SDR, and the spectrum test drives the line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:20:19 +02:00
sjg 25b9c31c9b [fix](trx-backend-soapysdr): squelch against the meter, not the post-AGC level
The threshold was compared against the block level measured after the IQ
AGC.  Holding that level at a setpoint is the AGC's entire purpose, so
for every mode that has one — FM, PKT, AIS, AM, SAM, which is to say the
modes anyone squelches — the comparison was against a near-constant.
With FM's 12 dB of gain a weak signal reads some 12 dB hotter than it
is, and the value never had the decimation correction the meter applies
on top of that: around 20 dB adrift at 48k/8k, more as decimation grows.

The threshold arrives in the other scale entirely.  The slider maps its
percentage onto -120..-30 dB and Auto takes the spectrum noise floor
plus 6, both of which are what the meter and the spectrum display show.
So a gate set just above the noise sat open on it.

It now reads last_signal_db, which is already computed each block before
the AGC and corrected for decimation — the same number the meter shows.
The post-AGC measurement had no other consumer.

The test feeds one signal twice and takes the threshold from the
channel's own meter: 6 dB above must gate it, 6 dB below must pass it.
Nothing there depends on the absolute scale, only on the two agreeing.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:03:46 +02:00
7 changed files with 619 additions and 206 deletions
@@ -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);