refactor: extract typed spectrum math
This commit is contained in:
@@ -37,6 +37,11 @@ import {
|
||||
updateTabHistory,
|
||||
} from "./features/navigation/routes.js";
|
||||
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.js";
|
||||
import {
|
||||
decodeBase64Int8 as decodeBase64ToInt8,
|
||||
estimateNoiseFloorDb,
|
||||
isNumericBins as isBinsArray,
|
||||
} from "./features/spectrum/math.js";
|
||||
|
||||
// --- Decoder registry (fetched from /decoders on load) ---
|
||||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||||
@@ -6072,38 +6077,6 @@ let waterfallGamma = 1.0;
|
||||
const SPECTRUM_HEADROOM_DB = 20;
|
||||
const SPECTRUM_SMOOTH_ALPHA = 0.42;
|
||||
let _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding
|
||||
// Fast base64 → Int8Array decoder using a lookup table.
|
||||
// Avoids atob() (which allocates a UTF-16 string) and the subsequent
|
||||
// charCodeAt loop, decoding directly into a reusable typed array.
|
||||
const _b64Lut = new Uint8Array(128);
|
||||
for (let i = 0; i < 128; i++) _b64Lut[i] = 255;
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").forEach((c, i) => {
|
||||
_b64Lut[c.charCodeAt(0)] = i;
|
||||
});
|
||||
let _spectrumBinI8 = new Int8Array(0); // Reusable typed-array bin buffer
|
||||
// Check if a value is an array-like bins buffer (Array or TypedArray).
|
||||
function isBinsArray(v) { return Array.isArray(v) || ArrayBuffer.isView(v); }
|
||||
function decodeBase64ToInt8(b64) {
|
||||
// Strip trailing '=' padding
|
||||
let end = b64.length;
|
||||
while (end > 0 && b64.charCodeAt(end - 1) === 61) end--;
|
||||
const outLen = (end * 3 >>> 2); // exact byte count without padding
|
||||
if (_spectrumBinI8.length !== outLen) _spectrumBinI8 = new Int8Array(outLen);
|
||||
const out = _spectrumBinI8;
|
||||
let j = 0;
|
||||
for (let i = 0; i < end; ) {
|
||||
const a = _b64Lut[b64.charCodeAt(i++)];
|
||||
const b = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0;
|
||||
const c = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0;
|
||||
const d = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0;
|
||||
const n = (a << 18) | (b << 12) | (c << 6) | d;
|
||||
if (j < outLen) out[j++] = (n >> 16) & 0xff;
|
||||
if (j < outLen) out[j++] = (n >> 8) & 0xff;
|
||||
if (j < outLen) out[j++] = n & 0xff;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Crosshair state (CSS coords relative to spectrum canvas).
|
||||
let spectrumCrosshairX = null;
|
||||
let spectrumCrosshairY = null;
|
||||
@@ -6237,35 +6210,6 @@ function buildSpectrumPeakHoldBins(currentBins) {
|
||||
return peakBins;
|
||||
}
|
||||
|
||||
// Estimate noise floor as the 15th-percentile of visible bins (same heuristic as Auto).
|
||||
// Uses O(N) nth-element selection instead of O(N log N) sort.
|
||||
function estimateNoiseFloorDb(bins) {
|
||||
if (!isBinsArray(bins) || bins.length === 0) return null;
|
||||
const k = Math.floor(bins.length * 0.15);
|
||||
return nthElement(bins, k);
|
||||
}
|
||||
|
||||
// O(N) average-case selection algorithm (Floyd-Rivest / quickselect).
|
||||
function nthElement(arr, k) {
|
||||
const tmp = _nthScratch.length >= arr.length ? _nthScratch : new Float64Array(arr.length);
|
||||
if (tmp.length > _nthScratch.length) _nthScratch = tmp;
|
||||
for (let i = 0; i < arr.length; i++) tmp[i] = arr[i];
|
||||
let lo = 0, hi = arr.length - 1;
|
||||
while (lo < hi) {
|
||||
const pivot = tmp[lo + ((hi - lo) >> 1)];
|
||||
let i = lo, j = hi;
|
||||
while (i <= j) {
|
||||
while (tmp[i] < pivot) i++;
|
||||
while (tmp[j] > pivot) j--;
|
||||
if (i <= j) { const t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; i++; j--; }
|
||||
}
|
||||
if (j < k) lo = i;
|
||||
if (k < i) hi = j;
|
||||
}
|
||||
return tmp[k];
|
||||
}
|
||||
let _nthScratch = new Float64Array(0);
|
||||
|
||||
// Pre-allocated buffer for smoothed spectrum bins (avoids .map() allocation per frame).
|
||||
let _smoothBins = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
export type NumericBins = number[] | Int8Array | Uint8Array | Float32Array | Float64Array;
|
||||
|
||||
export function isNumericBins(value: unknown): value is NumericBins {
|
||||
return Array.isArray(value)
|
||||
? value.every((item) => typeof item === "number")
|
||||
: ArrayBuffer.isView(value) && !(value instanceof DataView);
|
||||
}
|
||||
|
||||
const base64Lookup = new Uint8Array(128).fill(255);
|
||||
const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
for (let index = 0; index < base64Alphabet.length; index += 1) {
|
||||
base64Lookup[base64Alphabet.charCodeAt(index)] = index;
|
||||
}
|
||||
let spectrumBinBuffer = new Int8Array(0);
|
||||
|
||||
export function decodeBase64Int8(value: string): Int8Array {
|
||||
let end = value.length;
|
||||
while (end > 0 && value.charCodeAt(end - 1) === 61) end -= 1;
|
||||
const outputLength = end * 3 >>> 2;
|
||||
if (spectrumBinBuffer.length !== outputLength) spectrumBinBuffer = new Int8Array(outputLength);
|
||||
let outputIndex = 0;
|
||||
for (let index = 0; index < end;) {
|
||||
const sextets = [0, 0, 0, 0];
|
||||
for (let offset = 0; offset < 4 && index < end; offset += 1, index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
const decoded = code < base64Lookup.length ? base64Lookup[code] : undefined;
|
||||
if (decoded === undefined || decoded === 255) throw new TypeError("Invalid base64 spectrum frame");
|
||||
sextets[offset] = decoded;
|
||||
}
|
||||
const packed = ((sextets[0] ?? 0) << 18) | ((sextets[1] ?? 0) << 12)
|
||||
| ((sextets[2] ?? 0) << 6) | (sextets[3] ?? 0);
|
||||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 16 & 0xff;
|
||||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 0xff;
|
||||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 0xff;
|
||||
}
|
||||
return spectrumBinBuffer;
|
||||
}
|
||||
|
||||
let nthScratch = new Float64Array(0);
|
||||
|
||||
export function nthElement(values: NumericBins, target: number): number | null {
|
||||
if (values.length === 0 || target < 0 || target >= values.length) return null;
|
||||
if (nthScratch.length < values.length) nthScratch = new Float64Array(values.length);
|
||||
for (let index = 0; index < values.length; index += 1) nthScratch[index] = values[index] ?? 0;
|
||||
let low = 0;
|
||||
let high = values.length - 1;
|
||||
while (low < high) {
|
||||
const pivot = nthScratch[low + ((high - low) >> 1)] ?? 0;
|
||||
let left = low;
|
||||
let right = high;
|
||||
while (left <= right) {
|
||||
while ((nthScratch[left] ?? Infinity) < pivot) left += 1;
|
||||
while ((nthScratch[right] ?? -Infinity) > pivot) right -= 1;
|
||||
if (left <= right) {
|
||||
const temporary = nthScratch[left] ?? 0;
|
||||
nthScratch[left] = nthScratch[right] ?? 0;
|
||||
nthScratch[right] = temporary;
|
||||
left += 1;
|
||||
right -= 1;
|
||||
}
|
||||
}
|
||||
if (right < target) low = left;
|
||||
if (target < left) high = right;
|
||||
}
|
||||
return nthScratch[target] ?? null;
|
||||
}
|
||||
|
||||
export function estimateNoiseFloorDb(bins: unknown): number | null {
|
||||
if (!isNumericBins(bins) || bins.length === 0) return null;
|
||||
return nthElement(bins, Math.floor(bins.length * 0.15));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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 loadSpectrumMath() {
|
||||
const result = await build({
|
||||
entryPoints: [new URL("../src/features/spectrum/math.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,
|
||||
DataView,
|
||||
Float32Array,
|
||||
Float64Array,
|
||||
Int8Array,
|
||||
Uint8Array,
|
||||
Number,
|
||||
Math,
|
||||
TypeError,
|
||||
});
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
test("compact spectrum frames decode signed i8 bins", async () => {
|
||||
const { decodeBase64Int8 } = await loadSpectrumMath();
|
||||
const bins = decodeBase64Int8("/4AAfw==");
|
||||
assert.deepEqual(Array.from(bins), [-1, -128, 0, 127]);
|
||||
assert.throws(() => decodeBase64Int8("%%%"), /Invalid base64/);
|
||||
});
|
||||
|
||||
test("noise floor uses the lower spectrum percentile without sorting input", async () => {
|
||||
const { estimateNoiseFloorDb } = await loadSpectrumMath();
|
||||
const bins = new Float32Array([-70, -100, -95, -90, -80, -85, -75]);
|
||||
const original = Array.from(bins);
|
||||
assert.equal(estimateNoiseFloorDb(bins), -95);
|
||||
assert.deepEqual(Array.from(bins), original);
|
||||
assert.equal(estimateNoiseFloorDb(new Float32Array()), null);
|
||||
});
|
||||
Reference in New Issue
Block a user