[fix](trx-frontend-http): optimize spectrum and waterfall rendering performance

Six hot-path optimizations that reduce per-frame CPU cost:

1. Waterfall color LUT: Pre-compute a 256-entry RGBA lookup table
   (bins are i8 = 256 possible values) instead of calling
   waterfallColorRgba() per-pixel with HSL→RGB math + Math.pow().
   Eliminates ~2000+ HSL conversions per frame across both waterfalls.

2. Noise floor O(N)→O(N log N): Replace .slice().sort() with an
   in-place quickselect algorithm for 15th-percentile estimation.
   For 1024 bins this is ~10× faster.

3. Reuse spectrum bin buffers: SSE handler and buildSpectrumRenderData
   now reuse pre-allocated arrays instead of creating new Array(N)
   and .map() allocations every frame. Reduces GC pressure.

4. Cache canvas dimensions: drawSpectrum and drawSpectrumWaterfall
   read cached CSS dimensions instead of querying clientWidth/
   clientHeight every frame (which forces layout recalculation).
   Dimensions refreshed on resize and layout changes.

5. Cache DOM references: getElementById calls for zoom indicator and
   minimap elements moved to module-level constants instead of
   querying the DOM on every drawSpectrum call.

6. Efficient array trimming: Peak hold pruning uses in-place splice
   from front instead of .filter() (which allocates a new array).
   Waterfall row trimming uses splice instead of repeated .shift().

https://claude.ai/code/session_01G6wuNCkckbHHsU7w5zCtW2
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-28 18:00:09 +00:00
committed by Stan Grams
parent 9920094008
commit aacc7336d7
@@ -1384,10 +1384,9 @@ function pushHeaderSignalSample(sUnits) {
function trimOverviewWaterfallRows() { function trimOverviewWaterfallRows() {
if (!overviewCanvas) return; if (!overviewCanvas) return;
const dpr = window.devicePixelRatio || 1; const maxRows = Math.max(1, Math.floor(overviewCanvas.height / _cachedDpr));
const maxRows = Math.max(1, Math.floor(overviewCanvas.height / dpr)); if (overviewWaterfallRows.length > maxRows) {
while (overviewWaterfallRows.length > maxRows) { overviewWaterfallRows.splice(0, overviewWaterfallRows.length - maxRows);
overviewWaterfallRows.shift();
} }
} }
@@ -1465,20 +1464,17 @@ function drawOverviewWaterfall(W, H, pal) {
overviewWfTexWidth = iW; overviewWfTexWidth = iW;
overviewWfTexHeight = iH; overviewWfTexHeight = iH;
ensureWaterfallLut(pal, minDb, maxDb);
function renderRow(dstY, srcBins) { function renderRow(dstY, srcBins) {
if (!Array.isArray(srcBins) || srcBins.length === 0) return; if (!Array.isArray(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;
const iwM1 = Math.max(1, iW - 1);
for (let x = 0; x < iW; x++) { for (let x = 0; x < iW; x++) {
const frac = x / Math.max(1, iW - 1); const binIdx = Math.min(endIdx, startIdx + ((x * spanBins / iwM1) | 0));
const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins)); waterfallLutWrite(overviewWfTexData, rowBase + x * 4, srcBins[binIdx]);
const c = waterfallColorRgba(srcBins[binIdx], pal, minDb, maxDb);
const p = rowBase + x * 4;
overviewWfTexData[p + 0] = Math.round(c[0] * 255);
overviewWfTexData[p + 1] = Math.round(c[1] * 255);
overviewWfTexData[p + 2] = Math.round(c[2] * 255);
overviewWfTexData[p + 3] = Math.round(c[3] * 255);
} }
} }
@@ -1582,6 +1578,38 @@ function waterfallColorRgba(db, pal, minDb, maxDb) {
return cssColorToRgba(`hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`); return cssColorToRgba(`hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`);
} }
// 256-entry waterfall color lookup table (bins are i8 = 256 possible values).
// Eliminates per-pixel HSL→RGBA computation in the waterfall rendering hot path.
let _wfLutKey = "";
const _wfLut = new Uint8Array(256 * 4); // [r,g,b,a] × 256 entries, 0-255 range
function ensureWaterfallLut(pal, minDb, maxDb) {
const key = `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${minDb}|${maxDb}|${waterfallGamma}`;
if (key === _wfLutKey) return;
_wfLutKey = key;
for (let i = 0; i < 256; i++) {
// i8 range: -128 to 127 (dB values in the spectrum)
const db = i < 128 ? i : i - 256;
const c = waterfallColorRgba(db, pal, minDb, maxDb);
const p = i * 4;
_wfLut[p + 0] = (c[0] * 255 + 0.5) | 0;
_wfLut[p + 1] = (c[1] * 255 + 0.5) | 0;
_wfLut[p + 2] = (c[2] * 255 + 0.5) | 0;
_wfLut[p + 3] = (c[3] * 255 + 0.5) | 0;
}
}
// Fast waterfall pixel write using LUT. `db` is the raw i8 bin value.
function waterfallLutWrite(texData, offset, db) {
// Convert signed i8 to 0-255 LUT index
const idx = ((db | 0) + 256) & 0xFF;
const p = idx * 4;
texData[offset] = _wfLut[p];
texData[offset + 1] = _wfLut[p + 1];
texData[offset + 2] = _wfLut[p + 2];
texData[offset + 3] = _wfLut[p + 3];
}
function formatFreq(hz) { function formatFreq(hz) {
if (!Number.isFinite(hz)) return "--"; if (!Number.isFinite(hz)) return "--";
if (hz >= 1_000_000_000) { if (hz >= 1_000_000_000) {
@@ -2766,6 +2794,8 @@ function updateSpectrumAutoHeight() {
root.style.setProperty("--overview-plot-height", `${nextOverviewHeight}px`); root.style.setProperty("--overview-plot-height", `${nextOverviewHeight}px`);
root.style.setProperty("--spectrum-plot-height", `${nextSpectrumHeight}px`); root.style.setProperty("--spectrum-plot-height", `${nextSpectrumHeight}px`);
// Refresh cached canvas sizes after layout change.
if (typeof _updateCachedCanvasSizes === "function") _updateCachedCanvasSizes();
if (lastSpectrumData) { if (lastSpectrumData) {
scheduleSpectrumDraw(); scheduleSpectrumDraw();
scheduleOverviewDraw(); scheduleOverviewDraw();
@@ -9014,6 +9044,7 @@ let spectrumRange = 90;
let waterfallGamma = 1.0; 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
// Crosshair state (CSS coords relative to spectrum canvas). // Crosshair state (CSS coords relative to spectrum canvas).
let spectrumCrosshairX = null; let spectrumCrosshairX = null;
@@ -9102,9 +9133,14 @@ function pruneSpectrumPeakHoldFrames(now = Date.now()) {
clearSpectrumPeakHoldFrames(); clearSpectrumPeakHoldFrames();
return; return;
} }
spectrumPeakHoldFrames = spectrumPeakHoldFrames.filter((frame) => { // In-place removal from front (frames are time-ordered).
return frame && Array.isArray(frame.bins) && now - frame.t <= holdMs; let removeCount = 0;
}); for (let i = 0; i < spectrumPeakHoldFrames.length; i++) {
const f = spectrumPeakHoldFrames[i];
if (f && Array.isArray(f.bins) && now - f.t <= holdMs) break;
removeCount++;
}
if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount);
} }
function pushSpectrumPeakHoldFrame(frame) { function pushSpectrumPeakHoldFrame(frame) {
@@ -9144,27 +9180,60 @@ function buildSpectrumPeakHoldBins(currentBins) {
} }
// Estimate noise floor as the 15th-percentile of visible bins (same heuristic as Auto). // 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) { function estimateNoiseFloorDb(bins) {
if (!Array.isArray(bins) || bins.length === 0) return null; if (!Array.isArray(bins) || bins.length === 0) return null;
const sorted = bins.slice().sort((a, b) => a - b); const k = Math.floor(bins.length * 0.15);
return sorted[Math.floor(sorted.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 = [];
function buildSpectrumRenderData(frame) { function buildSpectrumRenderData(frame) {
if (!frame || !Array.isArray(frame.bins)) return frame; if (!frame || !Array.isArray(frame.bins)) return frame;
const n = frame.bins.length;
const prev = lastSpectrumRenderData; const prev = lastSpectrumRenderData;
const canBlend = const canBlend =
prev && prev &&
Array.isArray(prev.bins) && Array.isArray(prev.bins) &&
prev.bins.length === frame.bins.length && prev.bins.length === n &&
prev.sample_rate === frame.sample_rate && prev.sample_rate === frame.sample_rate &&
prev.center_hz === frame.center_hz; prev.center_hz === frame.center_hz;
const bins = frame.bins.map((value, idx) => { if (_smoothBins.length !== n) _smoothBins = new Array(n);
if (!canBlend) return value; const src = frame.bins;
const prevValue = prev.bins[idx]; if (canBlend) {
return prevValue + (value - prevValue) * SPECTRUM_SMOOTH_ALPHA; const prevBins = prev.bins;
}); const alpha = SPECTRUM_SMOOTH_ALPHA;
return { ...frame, bins }; for (let i = 0; i < n; i++) {
_smoothBins[i] = prevBins[i] + (src[i] - prevBins[i]) * alpha;
}
} else {
for (let i = 0; i < n; i++) _smoothBins[i] = src[i];
}
// Return object reusing the frame's metadata.
return { bins: _smoothBins, center_hz: frame.center_hz, sample_rate: frame.sample_rate, rds: frame.rds };
} }
// Returns { loHz, hiHz, visLoHz, visHiHz, fullSpanHz, visSpanHz } and clamps // Returns { loHz, hiHz, visLoHz, visHiHz, fullSpanHz, visSpanHz } and clamps
@@ -9353,8 +9422,10 @@ function startSpectrumStreaming() {
const b64 = evt.data.slice(commaB + 1); const b64 = evt.data.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData; const hadSpectrum = !!lastSpectrumData;
const raw = atob(b64); const raw = atob(b64);
const bins = new Array(raw.length); const len = raw.length;
for (let i = 0; i < raw.length; i++) bins[i] = (raw.charCodeAt(i) << 24 >> 24); if (_spectrumBinBuf.length !== len) _spectrumBinBuf = new Array(len);
const bins = _spectrumBinBuf;
for (let i = 0; i < len; i++) bins[i] = (raw.charCodeAt(i) << 24 >> 24);
// Preserve any RDS data from the last rds event. // Preserve any RDS data from the last rds event.
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 };
@@ -9718,9 +9789,9 @@ function scheduleSpectrumDraw() {
function drawSpectrum(data) { function drawSpectrum(data) {
if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return;
const dpr = window.devicePixelRatio || 1; const dpr = _cachedDpr;
const cssW = spectrumCanvas.clientWidth || 640; const cssW = _cachedSpectrumCssW;
const cssH = spectrumCanvas.clientHeight || 160; const cssH = _cachedSpectrumCssH;
spectrumGl.ensureSize(cssW, cssH, dpr); spectrumGl.ensureSize(cssW, cssH, dpr);
const W = spectrumCanvas.width; const W = spectrumCanvas.width;
const H = spectrumCanvas.height; const H = spectrumCanvas.height;
@@ -9808,33 +9879,30 @@ function drawSpectrum(data) {
} }
// ── Zoom indicator ── // ── Zoom indicator ──
const zoomEl = document.getElementById("spectrum-zoom-indicator"); if (_spectrumZoomEl) {
if (zoomEl) {
if (spectrumZoom > 1.01) { if (spectrumZoom > 1.01) {
zoomEl.textContent = spectrumZoom.toFixed(1) + "x"; _spectrumZoomEl.textContent = spectrumZoom.toFixed(1) + "x";
zoomEl.style.display = "block"; _spectrumZoomEl.style.display = "block";
} else { } else {
zoomEl.style.display = "none"; _spectrumZoomEl.style.display = "none";
} }
} }
// ── Zoom minimap ── // ── Zoom minimap ──
const minimapEl = document.getElementById("spectrum-minimap"); if (_spectrumMinimapEl) {
if (minimapEl) {
if (spectrumZoom > 1.01) { if (spectrumZoom > 1.01) {
minimapEl.style.display = "block"; _spectrumMinimapEl.style.display = "block";
const viewFrac = 1 / spectrumZoom; const viewFrac = 1 / spectrumZoom;
const halfVis = viewFrac / 2; const halfVis = viewFrac / 2;
const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis); const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
const viewL = panClamped - halfVis; const viewL = panClamped - halfVis;
const viewR = panClamped + halfVis; const viewR = panClamped + halfVis;
const inner = minimapEl.querySelector(".minimap-view"); if (_spectrumMinimapInner) {
if (inner) { _spectrumMinimapInner.style.left = (viewL * 100) + "%";
inner.style.left = (viewL * 100) + "%"; _spectrumMinimapInner.style.width = ((viewR - viewL) * 100) + "%";
inner.style.width = ((viewR - viewL) * 100) + "%";
} }
} else { } else {
minimapEl.style.display = "none"; _spectrumMinimapEl.style.display = "none";
} }
} }
@@ -9859,6 +9927,32 @@ let spectrumWfTexReady = false;
let spectrumWfDrawPending = false; let spectrumWfDrawPending = false;
const SPECTRUM_WF_TEX_MAX_W = 1024; const SPECTRUM_WF_TEX_MAX_W = 1024;
// Cached DOM references for drawSpectrum (avoid getElementById per frame).
const _spectrumZoomEl = document.getElementById("spectrum-zoom-indicator");
const _spectrumMinimapEl = document.getElementById("spectrum-minimap");
const _spectrumMinimapInner = _spectrumMinimapEl ? _spectrumMinimapEl.querySelector(".minimap-view") : null;
// Cached canvas dimensions (updated on resize instead of reading clientWidth/clientHeight per frame).
let _cachedSpectrumCssW = 640, _cachedSpectrumCssH = 160;
let _cachedSpecWfCssW = 640, _cachedSpecWfCssH = 120;
let _cachedDpr = window.devicePixelRatio || 1;
function _updateCachedCanvasSizes() {
_cachedDpr = window.devicePixelRatio || 1;
if (spectrumCanvas) {
_cachedSpectrumCssW = spectrumCanvas.clientWidth || 640;
_cachedSpectrumCssH = spectrumCanvas.clientHeight || 160;
}
if (spectrumWaterfallCanvas) {
_cachedSpecWfCssW = spectrumWaterfallCanvas.clientWidth || 640;
_cachedSpecWfCssH = spectrumWaterfallCanvas.clientHeight || 120;
}
}
// Refresh on resize; also called from scheduleSpectrumLayout.
window.addEventListener("resize", _updateCachedCanvasSizes);
// Initial read.
_updateCachedCanvasSizes();
function pushSpectrumWaterfallFrame(data) { function pushSpectrumWaterfallFrame(data) {
if (!spectrumWaterfallCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return; if (!spectrumWaterfallCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return;
spectrumWfRows.push(data.bins.slice()); spectrumWfRows.push(data.bins.slice());
@@ -9869,10 +9963,9 @@ function pushSpectrumWaterfallFrame(data) {
function trimSpectrumWaterfallRows() { function trimSpectrumWaterfallRows() {
if (!spectrumWaterfallCanvas) return; if (!spectrumWaterfallCanvas) return;
const dpr = window.devicePixelRatio || 1; const maxRows = Math.max(1, Math.floor(_cachedSpecWfCssH * _cachedDpr));
const maxRows = Math.max(1, Math.floor((spectrumWaterfallCanvas.clientHeight || 120) * dpr)); if (spectrumWfRows.length > maxRows) {
while (spectrumWfRows.length > maxRows) { spectrumWfRows.splice(0, spectrumWfRows.length - maxRows);
spectrumWfRows.shift();
} }
} }
@@ -9889,9 +9982,9 @@ function drawSpectrumWaterfall() {
if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return; if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return;
if (!lastSpectrumData || spectrumWfRows.length === 0) return; if (!lastSpectrumData || spectrumWfRows.length === 0) return;
const dpr = window.devicePixelRatio || 1; const dpr = _cachedDpr;
const cssW = spectrumWaterfallCanvas.clientWidth || 640; const cssW = _cachedSpecWfCssW;
const cssH = spectrumWaterfallCanvas.clientHeight || 120; const cssH = _cachedSpecWfCssH;
spectrumWaterfallGl.ensureSize(cssW, cssH, dpr); spectrumWaterfallGl.ensureSize(cssW, cssH, dpr);
const W = spectrumWaterfallCanvas.width; const W = spectrumWaterfallCanvas.width;
const H = spectrumWaterfallCanvas.height; const H = spectrumWaterfallCanvas.height;
@@ -9923,20 +10016,17 @@ function drawSpectrumWaterfall() {
spectrumWfTexWidth = iW; spectrumWfTexWidth = iW;
spectrumWfTexHeight = iH; spectrumWfTexHeight = iH;
ensureWaterfallLut(pal, minDb, maxDb);
function renderRow(dstY, srcBins) { function renderRow(dstY, srcBins) {
if (!Array.isArray(srcBins) || srcBins.length === 0) return; if (!Array.isArray(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;
const iwM1 = Math.max(1, iW - 1);
for (let x = 0; x < iW; x++) { for (let x = 0; x < iW; x++) {
const frac = x / Math.max(1, iW - 1); const binIdx = Math.min(endIdx, startIdx + ((x * spanBins / iwM1) | 0));
const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins)); waterfallLutWrite(spectrumWfTexData, rowBase + x * 4, srcBins[binIdx]);
const c = waterfallColorRgba(srcBins[binIdx], pal, minDb, maxDb);
const p = rowBase + x * 4;
spectrumWfTexData[p + 0] = Math.round(c[0] * 255);
spectrumWfTexData[p + 1] = Math.round(c[1] * 255);
spectrumWfTexData[p + 2] = Math.round(c[2] * 255);
spectrumWfTexData[p + 3] = Math.round(c[3] * 255);
} }
} }