98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
// 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);
|
|
}
|