Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/radio/auto-bandwidth.ts
T

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);
}