refactor: extract typed auto bandwidth estimator

This commit is contained in:
sjg
2026-08-01 17:59:04 +02:00
parent 1fb196a64e
commit e300cc343d
4 changed files with 241 additions and 174 deletions
@@ -390,6 +390,77 @@
else window.history.pushState({}, "", nextUrl); else window.history.pushState({}, "", nextUrl);
} }
// src/features/radio/auto-bandwidth.ts
function clampPercent(value) {
const numeric = Number(value) || 0;
return Math.max(0, Math.min(100, numeric)) / 100;
}
function estimateOccupiedBandwidth(data, centerHz, mode, limits, interference = {}) {
if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins) || !Number.isFinite(centerHz)) return null;
const bins = Array.from(data.bins);
if (bins.length < 3) return null;
const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max(
1,
Math.min(maxIdx - 1, Math.round((centerHz - fullLoHz) / data.sample_rate * maxIdx))
);
const normalizedMode = mode.toUpperCase();
const [defaultBw, minBw, maxBw, stepBw] = limits;
const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode) ? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0;
const isWfm = normalizedMode === "WFM";
const smoothRadius = isWfm ? 3 : 1;
const smoothed = bins.map((_, index) => {
let sum = 0;
let count = 0;
for (let adjacent = Math.max(0, index - smoothRadius); adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) {
sum += bins[adjacent] ?? 0;
count += 1;
}
return sum / count;
});
const sorted = [...bins].sort((left, right) => left - right);
const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity;
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
let peak = -Infinity;
for (let index = searchLo; index <= searchHi; index += 1) {
peak = Math.max(peak, smoothed[index] ?? -Infinity);
}
const snr = peak - noise;
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12e3 : stepBw) / hzPerBin));
const occupiedExtent = (direction, limitBins) => {
let lastOccupied = centerIdx;
let gap = 0;
for (let offset = 0; offset <= limitBins; offset += 1) {
const index = centerIdx + direction * offset;
if (index <= 0 || index >= maxIdx) break;
if ((smoothed[index] ?? -Infinity) >= threshold) {
lastOccupied = index;
gap = 0;
} else if (++gap > allowedGap) break;
}
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
};
let rawBw = oneSided !== 0 ? occupiedExtent(oneSided, maxSpanBins) : 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins));
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) {
const aci = clampPercent(interference.aci);
const cci = clampPercent(interference.cci);
const aciCap = maxBw - (maxBw - minBw) * aci;
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
const cciCap = maxBw - (maxBw - cciFloor) * cci;
rawBw = Math.min(rawBw, aciCap, cciCap);
}
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
}
// src/app.js // src/app.js
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null; var authRole = null;
@@ -4089,84 +4160,18 @@ ${unsupportedBandSummary()}`;
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } }); window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
} }
} }
function estimateOccupiedBandwidth(data, centerHz, interference = {}) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) {
return null;
}
const bins = data.bins;
const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max(
1,
Math.min(maxIdx - 1, Math.round((centerHz - fullLoHz) / data.sample_rate * maxIdx))
);
const mode = (modeEl ? modeEl.value : "USB").toUpperCase();
const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode);
const oneSided = mode === "USB" || mode === "DIG" || mode === "CW" ? 1 : mode === "LSB" || mode === "CWR" ? -1 : 0;
const isWfm = mode === "WFM";
const smoothRadius = isWfm ? 3 : 1;
const smoothed = bins.map((_, i) => {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) {
sum += bins[j];
count += 1;
}
return sum / count;
});
const sorted = [...bins].sort((a, b) => a - b);
const noise = sorted[Math.floor(sorted.length * 0.2)];
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
let peak = -Infinity;
for (let i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]);
const snr = peak - noise;
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12e3 : stepBw) / hzPerBin));
function occupiedExtent(direction, limitBins) {
let lastOccupied = centerIdx;
let gap = 0;
for (let n = 0; n <= limitBins; n++) {
const i = centerIdx + direction * n;
if (i <= 0 || i >= maxIdx) break;
if (smoothed[i] >= threshold) {
lastOccupied = i;
gap = 0;
} else if (++gap > allowedGap) {
break;
}
}
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
}
let rawBw;
if (oneSided !== 0) {
rawBw = occupiedExtent(oneSided, maxSpanBins);
} else {
const leftHz = occupiedExtent(-1, searchHalfBins);
const rightHz = occupiedExtent(1, searchHalfBins);
rawBw = 2 * Math.max(leftHz, rightHz);
}
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) {
const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100;
const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100;
const aciCap = maxBw - (maxBw - minBw) * aci;
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
const cciCap = maxBw - (maxBw - cciFloor) * cci;
rawBw = Math.min(rawBw, aciCap, cciCap);
}
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
}
async function applyAutoBandwidth() { async function applyAutoBandwidth() {
if (!lastSpectrumData || lastFreqHz == null) return; if (!lastSpectrumData || lastFreqHz == null) return;
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true; const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci };
const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference); const mode = (modeEl?.value || "").toUpperCase();
const estimated = estimateOccupiedBandwidth(
lastSpectrumData,
lastFreqHz,
mode,
mwDefaultsForMode(mode),
interference
);
if (!Number.isFinite(estimated) || estimated <= 0) { if (!Number.isFinite(estimated) || estimated <= 0) {
syncBandwidthInput(currentBandwidthHz); syncBandwidthInput(currentBandwidthHz);
return; return;
@@ -4178,7 +4183,6 @@ ${unsupportedBandSummary()}`;
if (lastSpectrumData) { if (lastSpectrumData) {
scheduleSpectrumDraw(); scheduleSpectrumDraw();
} }
const mode = (modeEl?.value || "").toUpperCase();
let reason = "measured occupied spectrum"; let reason = "measured occupied spectrum";
if (mode === "WFM") { if (mode === "WFM") {
if (estimated === 6e4 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`; if (estimated === 6e4 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
@@ -36,6 +36,7 @@ import {
tabFromPath as tabFromPathname, tabFromPath as tabFromPathname,
updateTabHistory, updateTabHistory,
} from "./features/navigation/routes.js"; } from "./features/navigation/routes.js";
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js";
// --- Decoder registry (fetched from /decoders on load) --- // --- Decoder registry (fetched from /decoders on load) ---
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
@@ -4109,110 +4110,20 @@ async function applyBandwidthFromInput() {
} }
} }
function estimateOccupiedBandwidth(data, centerHz, interference = {}) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) {
return null;
}
const bins = data.bins;
const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max(
1,
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
);
const mode = (modeEl ? modeEl.value : "USB").toUpperCase();
const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode);
const oneSided = mode === "USB" || mode === "DIG" || mode === "CW"
? 1
: mode === "LSB" || mode === "CWR" ? -1 : 0;
const isWfm = mode === "WFM";
// Reduce single-bin peaks and holes before finding occupied-channel edges.
// WFM needs a wider smoothing window because its energy is noise-like and
// spread across the entire channel rather than concentrated at a carrier.
const smoothRadius = isWfm ? 3 : 1;
const smoothed = bins.map((_, i) => {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) {
sum += bins[j];
count += 1;
}
return sum / count;
});
const sorted = [...bins].sort((a, b) => a - b);
const noise = sorted[Math.floor(sorted.length * 0.2)];
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
let peak = -Infinity;
for (let i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]);
const snr = peak - noise;
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
// A threshold relative to the noise floor finds occupied bandwidth much
// more reliably than one relative to the peak. The latter fails for WFM,
// whose multiplex spectrum has peaks, notches, and no narrow centre carrier.
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin));
function occupiedExtent(direction, limitBins) {
let lastOccupied = centerIdx;
let gap = 0;
for (let n = 0; n <= limitBins; n++) {
const i = centerIdx + direction * n;
if (i <= 0 || i >= maxIdx) break;
if (smoothed[i] >= threshold) {
lastOccupied = i;
gap = 0;
} else if (++gap > allowedGap) {
break;
}
}
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
}
let rawBw;
if (oneSided !== 0) {
rawBw = occupiedExtent(oneSided, maxSpanBins);
} else {
const leftHz = occupiedExtent(-1, searchHalfBins);
const rightHz = occupiedExtent(1, searchHalfBins);
// A symmetric RF filter must contain the larger of the two sidebands.
rawBw = 2 * Math.max(leftHz, rightHz);
}
// Add a transition-band margin. Weak WFM deliberately falls back to the
// 60 kHz mode floor above: a narrower filter trades stereo/RDS content for
// a useful improvement in intelligibility when the signal is very poor.
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) {
const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100;
const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100;
// Adjacent-channel energy is outside the wanted modulation, so ACI can
// safely drive the cap all the way from the 300 kHz ceiling to 60 kHz.
const aciCap = maxBw - (maxBw - minBw) * aci;
// CCI overlaps the wanted station and cannot be removed by an RF filter.
// Only distrust the widest edge estimates, retaining at least 65% of the
// useful range between the weak-signal floor and nominal WFM bandwidth.
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
const cciCap = maxBw - (maxBw - cciFloor) * cci;
rawBw = Math.min(rawBw, aciCap, cciCap);
}
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
}
async function applyAutoBandwidth() { async function applyAutoBandwidth() {
if (!lastSpectrumData || lastFreqHz == null) return; if (!lastSpectrumData || lastFreqHz == null) return;
// WFM interference telemetry belongs to the primary DSP channel. Do not // WFM interference telemetry belongs to the primary DSP channel. Do not
// apply it to a virtual channel, where it would describe the wrong signal. // apply it to a virtual channel, where it would describe the wrong signal.
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true; const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci };
const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference); const mode = (modeEl?.value || "").toUpperCase();
const estimated = estimateOccupiedBandwidth(
lastSpectrumData,
lastFreqHz,
mode,
mwDefaultsForMode(mode),
interference,
);
if (!Number.isFinite(estimated) || estimated <= 0) { if (!Number.isFinite(estimated) || estimated <= 0) {
syncBandwidthInput(currentBandwidthHz); syncBandwidthInput(currentBandwidthHz);
return; return;
@@ -4224,7 +4135,6 @@ async function applyAutoBandwidth() {
if (lastSpectrumData) { if (lastSpectrumData) {
scheduleSpectrumDraw(); scheduleSpectrumDraw();
} }
const mode = (modeEl?.value || "").toUpperCase();
let reason = "measured occupied spectrum"; let reason = "measured occupied spectrum";
if (mode === "WFM") { if (mode === "WFM") {
if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`; if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export interface SpectrumFrame {
bins: readonly number[] | ArrayBufferView;
center_hz: number;
sample_rate: number;
}
export interface InterferenceLevels { aci?: number; cci?: number }
export type BandwidthLimits = readonly [defaultHz: number, minHz: number, maxHz: number, stepHz: number];
function clampPercent(value: unknown): number {
const numeric = Number(value) || 0;
return Math.max(0, Math.min(100, numeric)) / 100;
}
export function estimateOccupiedBandwidth(
data: SpectrumFrame | null,
centerHz: number,
mode: string,
limits: BandwidthLimits,
interference: InterferenceLevels = {},
): number | null {
if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins)
|| !Number.isFinite(centerHz)) return null;
const bins = Array.from(data.bins as ArrayLike<number>);
if (bins.length < 3) return null;
const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max(
1,
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
);
const normalizedMode = mode.toUpperCase();
const [defaultBw, minBw, maxBw, stepBw] = limits;
const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode)
? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0;
const isWfm = normalizedMode === "WFM";
const smoothRadius = isWfm ? 3 : 1;
const smoothed = bins.map((_, index) => {
let sum = 0;
let count = 0;
for (let adjacent = Math.max(0, index - smoothRadius);
adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) {
sum += bins[adjacent] ?? 0;
count += 1;
}
return sum / count;
});
const sorted = [...bins].sort((left, right) => left - right);
const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity;
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
let peak = -Infinity;
for (let index = searchLo; index <= searchHi; index += 1) {
peak = Math.max(peak, smoothed[index] ?? -Infinity);
}
const snr = peak - noise;
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin));
const occupiedExtent = (direction: -1 | 1, limitBins: number): number => {
let lastOccupied = centerIdx;
let gap = 0;
for (let offset = 0; offset <= limitBins; offset += 1) {
const index = centerIdx + direction * offset;
if (index <= 0 || index >= maxIdx) break;
if ((smoothed[index] ?? -Infinity) >= threshold) {
lastOccupied = index;
gap = 0;
} else if (++gap > allowedGap) break;
}
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
};
let rawBw = oneSided !== 0
? occupiedExtent(oneSided, maxSpanBins)
: 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins));
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) {
const aci = clampPercent(interference.aci);
const cci = clampPercent(interference.cci);
const aciCap = maxBw - (maxBw - minBw) * aci;
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
const cciCap = maxBw - (maxBw - cciFloor) * cci;
rawBw = Math.min(rawBw, aciCap, cciCap);
}
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
}
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { build } from "esbuild";
async function loadEstimator() {
const result = await build({
entryPoints: [new URL("../src/features/radio/auto-bandwidth.ts", import.meta.url).pathname],
bundle: true,
format: "cjs",
platform: "browser",
target: "es2022",
write: false,
});
const module = { exports: {} };
vm.runInNewContext(result.outputFiles[0].text, {
module,
exports: module.exports,
Array,
ArrayBuffer,
Number,
Math,
});
return module.exports.estimateOccupiedBandwidth;
}
function wfmFrame(signalHalfWidthHz, signalDb = -70) {
const sampleRate = 400_000;
const bins = Array.from({ length: 1025 }, (_, index) => {
const offsetHz = (index / 1024 - 0.5) * sampleRate;
return Math.abs(offsetHz) <= signalHalfWidthHz ? signalDb : -100;
});
return { bins, center_hz: 100_000_000, sample_rate: sampleRate };
}
test("weak WFM falls back to the 60 kHz intelligibility floor", async () => {
const estimate = await loadEstimator();
const weak = wfmFrame(90_000, -97);
assert.equal(estimate(weak, 100_000_000, "WFM", [180_000, 60_000, 300_000, 5_000]), 60_000);
});
test("WFM ACI and CCI cap an otherwise wide occupied estimate", async () => {
const estimate = await loadEstimator();
const frame = wfmFrame(95_000);
const limits = [180_000, 60_000, 300_000, 5_000];
const clear = estimate(frame, 100_000_000, "WFM", limits);
const adjacent = estimate(frame, 100_000_000, "WFM", limits, { aci: 100 });
const cochannel = estimate(frame, 100_000_000, "WFM", limits, { cci: 100 });
assert.ok(clear > adjacent);
assert.equal(adjacent, 60_000);
assert.ok(cochannel >= 135_000 && cochannel < clear);
});