refactor: extract typed spectrum math

This commit is contained in:
sjg
2026-08-01 18:19:02 +02:00
parent e300cc343d
commit 5136704826
4 changed files with 213 additions and 138 deletions
@@ -461,6 +461,69 @@
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw); return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
} }
// src/features/spectrum/math.ts
function isNumericBins(value) {
return Array.isArray(value) ? value.every((item) => typeof item === "number") : ArrayBuffer.isView(value) && !(value instanceof DataView);
}
var base64Lookup = new Uint8Array(128).fill(255);
var base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
for (let index = 0; index < base64Alphabet.length; index += 1) {
base64Lookup[base64Alphabet.charCodeAt(index)] = index;
}
var spectrumBinBuffer = new Int8Array(0);
function decodeBase64Int8(value) {
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] : void 0;
if (decoded === void 0 || 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 & 255;
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 255;
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 255;
}
return spectrumBinBuffer;
}
var nthScratch = new Float64Array(0);
function nthElement(values, target) {
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;
}
function estimateNoiseFloorDb(bins) {
if (!isNumericBins(bins) || bins.length === 0) return null;
return nthElement(bins, Math.floor(bins.length * 0.15));
}
// src/app.js // src/app.js
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null; var authRole = null;
@@ -1814,7 +1877,7 @@
return { startIdx, endIdx }; return { startIdx, endIdx };
} }
function pushOverviewWaterfallFrame(data) { function pushOverviewWaterfallFrame(data) {
if (!overviewCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; if (!overviewCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return;
overviewWaterfallRows.push(data.bins.slice()); overviewWaterfallRows.push(data.bins.slice());
overviewWaterfallPushCount++; overviewWaterfallPushCount++;
trimOverviewWaterfallRows(); trimOverviewWaterfallRows();
@@ -1869,7 +1932,7 @@
overviewWfTexHeight = iH; overviewWfTexHeight = iH;
ensureWaterfallLut(pal, minDb, maxDb); ensureWaterfallLut(pal, minDb, maxDb);
function renderRow(dstY, srcBins) { function renderRow(dstY, srcBins) {
if (!isBinsArray(srcBins) || srcBins.length === 0) return; if (!isNumericBins(srcBins) || srcBins.length === 0) return;
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
const spanBins = Math.max(1, endIdx - startIdx); const spanBins = Math.max(1, endIdx - startIdx);
const rowBase = dstY * rowStride; const rowBase = dstY * rowStride;
@@ -2440,7 +2503,7 @@
}); });
} }
function spectrumBinIndexForHz(data, hz) { function spectrumBinIndexForHz(data, hz) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 2 || !Number.isFinite(hz)) { if (!data || !isNumericBins(data.bins) || data.bins.length < 2 || !Number.isFinite(hz)) {
return null; return null;
} }
const maxIdx = data.bins.length - 1; const maxIdx = data.bins.length - 1;
@@ -2454,7 +2517,7 @@
return 10 ** (clamped / 10); return 10 ** (clamped / 10);
} }
function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 16) { if (!data || !isNumericBins(data.bins) || data.bins.length < 16) {
return null; return null;
} }
if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
@@ -4590,7 +4653,7 @@ ${unsupportedBandSummary()}`;
currentStyle, currentStyle,
cssColorToRgba, cssColorToRgba,
rgbaWithAlpha, rgbaWithAlpha,
isBinsArray, isBinsArray: isNumericBins,
estimateNoiseFloorDb, estimateNoiseFloorDb,
spectrumVisibleRange, spectrumVisibleRange,
drawSpectrum, drawSpectrum,
@@ -4938,7 +5001,7 @@ ${unsupportedBandSummary()}`;
if (!sdrSquelchSupported) return; if (!sdrSquelchSupported) return;
let pct = 0; let pct = 0;
const data = lastSpectrumData || window.lastSpectrumData; const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) { if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins); const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && Number.isFinite(noiseDb)) { if (noiseDb != null && Number.isFinite(noiseDb)) {
const thresholdDb = noiseDb + 6; const thresholdDb = noiseDb + 6;
@@ -6129,34 +6192,6 @@ ${unsupportedBandSummary()}`;
var waterfallGamma = 1; var waterfallGamma = 1;
var SPECTRUM_HEADROOM_DB = 20; var SPECTRUM_HEADROOM_DB = 20;
var SPECTRUM_SMOOTH_ALPHA = 0.42; var SPECTRUM_SMOOTH_ALPHA = 0.42;
var _b64Lut = new Uint8Array(128);
for (let i = 0; i < 128; i++) _b64Lut[i] = 255;
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").forEach((c, i) => {
_b64Lut[c.charCodeAt(0)] = i;
});
var _spectrumBinI8 = new Int8Array(0);
function isBinsArray(v) {
return Array.isArray(v) || ArrayBuffer.isView(v);
}
function decodeBase64ToInt8(b64) {
let end = b64.length;
while (end > 0 && b64.charCodeAt(end - 1) === 61) end--;
const outLen = end * 3 >>> 2;
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 & 255;
if (j < outLen) out[j++] = n >> 8 & 255;
if (j < outLen) out[j++] = n & 255;
}
return out;
}
var spectrumCrosshairX = null; var spectrumCrosshairX = null;
var spectrumCrosshairY = null; var spectrumCrosshairY = null;
var _bwDragEdge = null; var _bwDragEdge = null;
@@ -6231,13 +6266,13 @@ ${unsupportedBandSummary()}`;
let removeCount = 0; let removeCount = 0;
for (let i = 0; i < spectrumPeakHoldFrames.length; i++) { for (let i = 0; i < spectrumPeakHoldFrames.length; i++) {
const f = spectrumPeakHoldFrames[i]; const f = spectrumPeakHoldFrames[i];
if (f && isBinsArray(f.bins) && now - f.t <= holdMs) break; if (f && isNumericBins(f.bins) && now - f.t <= holdMs) break;
removeCount++; removeCount++;
} }
if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount); if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount);
} }
function pushSpectrumPeakHoldFrame(frame) { function pushSpectrumPeakHoldFrame(frame) {
if (!frame || !isBinsArray(frame.bins) || frame.bins.length === 0) { if (!frame || !isNumericBins(frame.bins) || frame.bins.length === 0) {
clearSpectrumPeakHoldFrames(); clearSpectrumPeakHoldFrames();
return; return;
} }
@@ -6256,56 +6291,26 @@ ${unsupportedBandSummary()}`;
} }
function buildSpectrumPeakHoldBins(currentBins) { function buildSpectrumPeakHoldBins(currentBins) {
const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
if (holdMs <= 0 || !isBinsArray(currentBins) || currentBins.length === 0) { if (holdMs <= 0 || !isNumericBins(currentBins) || currentBins.length === 0) {
return null; return null;
} }
pruneSpectrumPeakHoldFrames(); pruneSpectrumPeakHoldFrames();
if (spectrumPeakHoldFrames.length === 0) return null; if (spectrumPeakHoldFrames.length === 0) return null;
const peakBins = currentBins.slice(); const peakBins = currentBins.slice();
for (const frame of spectrumPeakHoldFrames) { for (const frame of spectrumPeakHoldFrames) {
if (!frame || !isBinsArray(frame.bins) || frame.bins.length !== peakBins.length) continue; if (!frame || !isNumericBins(frame.bins) || frame.bins.length !== peakBins.length) continue;
for (let i = 0; i < peakBins.length; i++) { for (let i = 0; i < peakBins.length; i++) {
if (frame.bins[i] > peakBins[i]) peakBins[i] = frame.bins[i]; if (frame.bins[i] > peakBins[i]) peakBins[i] = frame.bins[i];
} }
} }
return peakBins; return peakBins;
} }
function estimateNoiseFloorDb(bins) {
if (!isBinsArray(bins) || bins.length === 0) return null;
const k = Math.floor(bins.length * 0.15);
return nthElement(bins, k);
}
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];
}
var _nthScratch = new Float64Array(0);
var _smoothBins = []; var _smoothBins = [];
function buildSpectrumRenderData(frame) { function buildSpectrumRenderData(frame) {
if (!frame || !isBinsArray(frame.bins)) return frame; if (!frame || !isNumericBins(frame.bins)) return frame;
const n = frame.bins.length; const n = frame.bins.length;
const prev = lastSpectrumRenderData; const prev = lastSpectrumRenderData;
const canBlend = prev && isBinsArray(prev.bins) && prev.bins.length === n && prev.sample_rate === frame.sample_rate && prev.center_hz === frame.center_hz; const canBlend = prev && isNumericBins(prev.bins) && prev.bins.length === n && prev.sample_rate === frame.sample_rate && prev.center_hz === frame.center_hz;
if (_smoothBins.length !== n) _smoothBins = new Array(n); if (_smoothBins.length !== n) _smoothBins = new Array(n);
const src = frame.bins; const src = frame.bins;
if (canBlend) { if (canBlend) {
@@ -6339,7 +6344,7 @@ ${unsupportedBandSummary()}`;
return range.visLoHz + cssX / cssW * range.visSpanHz; return range.visLoHz + cssX / cssW * range.visSpanHz;
} }
function nearestSpectrumPeak(cssX, cssW, data) { function nearestSpectrumPeak(cssX, cssW, data) {
if (!data || !isBinsArray(data.bins) || data.bins.length === 0 || cssW <= 0) { if (!data || !isNumericBins(data.bins) || data.bins.length === 0 || cssW <= 0) {
return null; return null;
} }
const bins = data.bins; const bins = data.bins;
@@ -6400,7 +6405,7 @@ ${unsupportedBandSummary()}`;
return nearestSpectrumPeakHz(cssX, cssW, data) ?? Math.round(canvasXToHz(cssX, cssW, range)); return nearestSpectrumPeakHz(cssX, cssW, data) ?? Math.round(canvasXToHz(cssX, cssW, range));
} }
function visibleSpectrumPeakIndices(data, limit = 24) { function visibleSpectrumPeakIndices(data, limit = 24) {
if (!data || !isBinsArray(data.bins) || data.bins.length < 3) { if (!data || !isNumericBins(data.bins) || data.bins.length < 3) {
return []; return [];
} }
const bins = data.bins; const bins = data.bins;
@@ -6470,7 +6475,7 @@ ${unsupportedBandSummary()}`;
const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
const b64 = evt.data.slice(commaB + 1); const b64 = evt.data.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData; const hadSpectrum = !!lastSpectrumData;
const bins = decodeBase64ToInt8(b64); const bins = decodeBase64Int8(b64);
const rds = lastSpectrumData?.rds; const rds = lastSpectrumData?.rds;
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds }; lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
window.lastSpectrumData = lastSpectrumData; window.lastSpectrumData = lastSpectrumData;
@@ -6914,7 +6919,7 @@ ${unsupportedBandSummary()}`;
spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i)); spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i));
} }
spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill)); spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill));
if (isBinsArray(peakHoldBins) && peakHoldBins.length === n) { if (isNumericBins(peakHoldBins) && peakHoldBins.length === n) {
spectrumTmpPeakPoints.length = 0; spectrumTmpPeakPoints.length = 0;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i)); spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i));
@@ -7011,7 +7016,7 @@ ${unsupportedBandSummary()}`;
window.addEventListener("resize", _updateCachedCanvasSizes); window.addEventListener("resize", _updateCachedCanvasSizes);
_updateCachedCanvasSizes(); _updateCachedCanvasSizes();
function pushSpectrumWaterfallFrame(data) { function pushSpectrumWaterfallFrame(data) {
if (!spectrumWaterfallCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; if (!spectrumWaterfallCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return;
spectrumWfRows.push(data.bins.slice()); spectrumWfRows.push(data.bins.slice());
spectrumWfPushCount++; spectrumWfPushCount++;
trimSpectrumWaterfallRows(); trimSpectrumWaterfallRows();
@@ -7067,7 +7072,7 @@ ${unsupportedBandSummary()}`;
spectrumWfTexHeight = iH; spectrumWfTexHeight = iH;
ensureWaterfallLut(pal, minDb, maxDb); ensureWaterfallLut(pal, minDb, maxDb);
function renderRow(dstY, srcBins) { function renderRow(dstY, srcBins) {
if (!isBinsArray(srcBins) || srcBins.length === 0) return; if (!isNumericBins(srcBins) || srcBins.length === 0) return;
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
const spanBins = Math.max(1, endIdx - startIdx); const spanBins = Math.max(1, endIdx - startIdx);
const rowBase = dstY * rowStride; const rowBase = dstY * rowStride;
@@ -7528,7 +7533,7 @@ ${unsupportedBandSummary()}`;
} else { } else {
let auto = 30; let auto = 30;
const data = lastSpectrumData || window.lastSpectrumData; const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) { if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins); const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && Number.isFinite(noiseDb)) { if (noiseDb != null && Number.isFinite(noiseDb)) {
const thresholdDb = noiseDb + 6; const thresholdDb = noiseDb + 6;
@@ -37,6 +37,11 @@ import {
updateTabHistory, updateTabHistory,
} from "./features/navigation/routes.js"; } from "./features/navigation/routes.js";
import { estimateOccupiedBandwidth } from "./features/radio/auto-bandwidth.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) --- // --- Decoder registry (fetched from /decoders on load) ---
void loadDecoderRegistry(refreshOperatorLayoutCapabilities); void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
@@ -6072,38 +6077,6 @@ let waterfallGamma = 1.0;
const SPECTRUM_HEADROOM_DB = 20; const SPECTRUM_HEADROOM_DB = 20;
const SPECTRUM_SMOOTH_ALPHA = 0.42; const SPECTRUM_SMOOTH_ALPHA = 0.42;
let _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding 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). // Crosshair state (CSS coords relative to spectrum canvas).
let spectrumCrosshairX = null; let spectrumCrosshairX = null;
let spectrumCrosshairY = null; let spectrumCrosshairY = null;
@@ -6237,35 +6210,6 @@ function buildSpectrumPeakHoldBins(currentBins) {
return peakBins; 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). // Pre-allocated buffer for smoothed spectrum bins (avoids .map() allocation per frame).
let _smoothBins = []; 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);
});