From 51367048266a8d1f7858716ca82f36f17e30d02b Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 18:19:02 +0200 Subject: [PATCH] refactor: extract typed spectrum math --- .../assets/web/generated/app.js | 159 +++++++++--------- .../trx-frontend-http/frontend/src/app.js | 66 +------- .../frontend/src/features/spectrum/math.ts | 75 +++++++++ .../frontend/tests/spectrum-math.test.mjs | 51 ++++++ 4 files changed, 213 insertions(+), 138 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/spectrum/math.ts create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-math.test.mjs diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 6f2c2ef5..d479db9a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -461,6 +461,69 @@ 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 void loadDecoderRegistry(refreshOperatorLayoutCapabilities); var authRole = null; @@ -1814,7 +1877,7 @@ return { startIdx, endIdx }; } 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()); overviewWaterfallPushCount++; trimOverviewWaterfallRows(); @@ -1869,7 +1932,7 @@ overviewWfTexHeight = iH; ensureWaterfallLut(pal, minDb, maxDb); 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 spanBins = Math.max(1, endIdx - startIdx); const rowBase = dstY * rowStride; @@ -2440,7 +2503,7 @@ }); } 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; } const maxIdx = data.bins.length - 1; @@ -2454,7 +2517,7 @@ return 10 ** (clamped / 10); } 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; } if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { @@ -4590,7 +4653,7 @@ ${unsupportedBandSummary()}`; currentStyle, cssColorToRgba, rgbaWithAlpha, - isBinsArray, + isBinsArray: isNumericBins, estimateNoiseFloorDb, spectrumVisibleRange, drawSpectrum, @@ -4938,7 +5001,7 @@ ${unsupportedBandSummary()}`; if (!sdrSquelchSupported) return; let pct = 0; 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); if (noiseDb != null && Number.isFinite(noiseDb)) { const thresholdDb = noiseDb + 6; @@ -6129,34 +6192,6 @@ ${unsupportedBandSummary()}`; var waterfallGamma = 1; var SPECTRUM_HEADROOM_DB = 20; 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 spectrumCrosshairY = null; var _bwDragEdge = null; @@ -6231,13 +6266,13 @@ ${unsupportedBandSummary()}`; let removeCount = 0; for (let i = 0; i < spectrumPeakHoldFrames.length; 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++; } if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount); } function pushSpectrumPeakHoldFrame(frame) { - if (!frame || !isBinsArray(frame.bins) || frame.bins.length === 0) { + if (!frame || !isNumericBins(frame.bins) || frame.bins.length === 0) { clearSpectrumPeakHoldFrames(); return; } @@ -6256,56 +6291,26 @@ ${unsupportedBandSummary()}`; } function buildSpectrumPeakHoldBins(currentBins) { 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; } pruneSpectrumPeakHoldFrames(); if (spectrumPeakHoldFrames.length === 0) return null; const peakBins = currentBins.slice(); 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++) { if (frame.bins[i] > peakBins[i]) peakBins[i] = frame.bins[i]; } } 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 = []; function buildSpectrumRenderData(frame) { - if (!frame || !isBinsArray(frame.bins)) return frame; + if (!frame || !isNumericBins(frame.bins)) return frame; const n = frame.bins.length; 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); const src = frame.bins; if (canBlend) { @@ -6339,7 +6344,7 @@ ${unsupportedBandSummary()}`; return range.visLoHz + cssX / cssW * range.visSpanHz; } 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; } const bins = data.bins; @@ -6400,7 +6405,7 @@ ${unsupportedBandSummary()}`; return nearestSpectrumPeakHz(cssX, cssW, data) ?? Math.round(canvasXToHz(cssX, cssW, range)); } function visibleSpectrumPeakIndices(data, limit = 24) { - if (!data || !isBinsArray(data.bins) || data.bins.length < 3) { + if (!data || !isNumericBins(data.bins) || data.bins.length < 3) { return []; } const bins = data.bins; @@ -6470,7 +6475,7 @@ ${unsupportedBandSummary()}`; const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); const b64 = evt.data.slice(commaB + 1); const hadSpectrum = !!lastSpectrumData; - const bins = decodeBase64ToInt8(b64); + const bins = decodeBase64Int8(b64); const rds = lastSpectrumData?.rds; lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds }; window.lastSpectrumData = lastSpectrumData; @@ -6914,7 +6919,7 @@ ${unsupportedBandSummary()}`; spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i)); } spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill)); - if (isBinsArray(peakHoldBins) && peakHoldBins.length === n) { + if (isNumericBins(peakHoldBins) && peakHoldBins.length === n) { spectrumTmpPeakPoints.length = 0; for (let i = 0; i < n; i++) { spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i)); @@ -7011,7 +7016,7 @@ ${unsupportedBandSummary()}`; window.addEventListener("resize", _updateCachedCanvasSizes); _updateCachedCanvasSizes(); 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()); spectrumWfPushCount++; trimSpectrumWaterfallRows(); @@ -7067,7 +7072,7 @@ ${unsupportedBandSummary()}`; spectrumWfTexHeight = iH; ensureWaterfallLut(pal, minDb, maxDb); 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 spanBins = Math.max(1, endIdx - startIdx); const rowBase = dstY * rowStride; @@ -7528,7 +7533,7 @@ ${unsupportedBandSummary()}`; } else { let auto = 30; 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); if (noiseDb != null && Number.isFinite(noiseDb)) { const thresholdDb = noiseDb + 6; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js index f7eac405..b87861f4 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js @@ -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 = []; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/spectrum/math.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/spectrum/math.ts new file mode 100644 index 00000000..2fafdde3 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/spectrum/math.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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)); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-math.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-math.test.mjs new file mode 100644 index 00000000..f41ebce0 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-math.test.mjs @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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); +});