[perf](trx-frontend-http): reduce spectrum rate to 5 Hz and cache waterfall offscreen

Lower SPECTRUM_POLL_INTERVAL and SSE tick from 100 ms to 200 ms to halve
the number of spectrum frames pushed to the browser.

Introduce an OffscreenCanvas cache for the overview waterfall: at steady
state only the new row is painted and the existing image is scrolled up,
reducing per-frame work from O(rows × cols) to O(cols).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-28 02:13:00 +01:00
parent f65135cb5e
commit eb3b45da64
3 changed files with 72 additions and 14 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ impl RemoteEndpoint {
} }
} }
const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(100); const SPECTRUM_POLL_INTERVAL: Duration = Duration::from_millis(200);
pub struct RemoteClientConfig { pub struct RemoteClientConfig {
pub addr: String, pub addr: String,
@@ -627,6 +627,16 @@ let overviewSignalTimer = null;
let overviewWaterfallRows = []; let overviewWaterfallRows = [];
const HEADER_SIG_WINDOW_MS = 10_000; const HEADER_SIG_WINDOW_MS = 10_000;
// Offscreen waterfall cache — reused across frames to avoid full redraws
let _wfOC = null; // OffscreenCanvas
let _wfOCPalKey = ""; // palette signature when offscreen was last built
let _wfOCRowCount = 0; // number of rows currently rendered into offscreen
function _wfResetOffscreen() { _wfOC = null; _wfOCRowCount = 0; _wfOCPalKey = ""; }
function _wfPalKey(pal) {
return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}`;
}
function resizeHeaderSignalCanvas() { function resizeHeaderSignalCanvas() {
if (!overviewCanvas) return; if (!overviewCanvas) return;
const cssW = Math.floor(overviewCanvas.clientWidth); const cssW = Math.floor(overviewCanvas.clientWidth);
@@ -638,6 +648,7 @@ function resizeHeaderSignalCanvas() {
if (overviewCanvas.width !== nextW || overviewCanvas.height !== nextH) { if (overviewCanvas.width !== nextW || overviewCanvas.height !== nextH) {
overviewCanvas.width = nextW; overviewCanvas.width = nextW;
overviewCanvas.height = nextH; overviewCanvas.height = nextH;
_wfResetOffscreen();
trimOverviewWaterfallRows(); trimOverviewWaterfallRows();
} }
drawHeaderSignalGraph(); drawHeaderSignalGraph();
@@ -721,26 +732,71 @@ function drawHeaderSignalGraph() {
ctx.restore(); ctx.restore();
} }
function drawOverviewWaterfall(ctx, w, h, pal) { function _wfDrawRows(oct, rows, startRowIdx, endRowIdx, iW, iH, pal) {
const rows = overviewWaterfallRows.slice(-Math.max(1, Math.floor(h))); // Draw rows[startRowIdx..endRowIdx) into oct, positioned at the canvas bottom.
if (rows.length === 0) return; // rowH is computed relative to the total row count (all of `rows`).
const rowH = h / rows.length; const total = rows.length;
const columnStep = Math.max(1, Math.ceil(w / 320)); const rowH = iH / total;
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) { const columnStep = Math.max(1, Math.ceil(iW / 320));
const bins = rows[rowIdx]; for (let ri = startRowIdx; ri < endRowIdx; ri++) {
const bins = rows[ri];
if (!Array.isArray(bins) || bins.length === 0) continue; if (!Array.isArray(bins) || bins.length === 0) continue;
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, bins.length); const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, bins.length);
const spanBins = Math.max(1, endIdx - startIdx); const spanBins = Math.max(1, endIdx - startIdx);
const y = h - (rows.length - rowIdx) * rowH; const y = iH - (total - ri) * rowH;
for (let x = 0; x < w; x += columnStep) { for (let x = 0; x < iW; x += columnStep) {
const frac = x / Math.max(1, w - 1); const frac = x / Math.max(1, iW - 1);
const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins)); const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins));
ctx.fillStyle = waterfallColor(bins[binIdx], pal); oct.fillStyle = waterfallColor(bins[binIdx], pal);
ctx.fillRect(x, y, columnStep + 0.75, rowH + 1); oct.fillRect(x, y, columnStep + 0.75, rowH + 1);
} }
} }
} }
function drawOverviewWaterfall(ctx, w, h, pal) {
const maxVisible = Math.max(1, Math.floor(h));
const rows = overviewWaterfallRows.slice(-maxVisible);
if (rows.length === 0) return;
const iW = Math.ceil(w);
const iH = Math.ceil(h);
const palKey = _wfPalKey(pal);
const steadyState = rows.length >= maxVisible;
// Detect conditions that require a full redraw
const sizeChanged = !_wfOC || _wfOC.width !== iW || _wfOC.height !== iH;
const palChanged = _wfOCPalKey !== palKey;
const rowsShrank = rows.length < _wfOCRowCount;
const needsFull = sizeChanged || palChanged || rowsShrank || _wfOCRowCount === 0;
if (sizeChanged || !_wfOC) {
_wfOC = new OffscreenCanvas(iW, iH);
_wfOCRowCount = 0;
}
const oct = _wfOC.getContext("2d");
if (needsFull) {
oct.clearRect(0, 0, iW, iH);
_wfDrawRows(oct, rows, 0, rows.length, iW, iH, pal);
_wfOCRowCount = rows.length;
_wfOCPalKey = palKey;
} else if (steadyState && rows.length > _wfOCRowCount) {
// Steady state: scroll up and paint only the new rows at the bottom
const newCount = rows.length - _wfOCRowCount;
const rowH = iH / rows.length;
const scrollPx = Math.round(newCount * rowH);
if (scrollPx > 0 && scrollPx < iH) {
const img = oct.getImageData(0, scrollPx, iW, iH - scrollPx);
oct.putImageData(img, 0, 0);
oct.clearRect(0, iH - scrollPx, iW, scrollPx);
}
_wfDrawRows(oct, rows, rows.length - newCount, rows.length, iW, iH, pal);
_wfOCRowCount = rows.length;
}
ctx.drawImage(_wfOC, 0, 0, w, h);
}
function drawOverviewSignalHistory(ctx, w, h, pal) { function drawOverviewSignalHistory(ctx, w, h, pal) {
const now = Date.now(); const now = Date.now();
const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS); const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS);
@@ -2792,6 +2848,7 @@ function startSpectrumStreaming() {
if (evt.data === "null") { if (evt.data === "null") {
lastSpectrumData = null; lastSpectrumData = null;
overviewWaterfallRows = []; overviewWaterfallRows = [];
_wfResetOffscreen();
scheduleOverviewDraw(); scheduleOverviewDraw();
clearSpectrumCanvas(); clearSpectrumCanvas();
updateRdsPsOverlay(null); updateRdsPsOverlay(null);
@@ -2826,6 +2883,7 @@ function stopSpectrumStreaming() {
spectrumDrawPending = false; spectrumDrawPending = false;
lastSpectrumData = null; lastSpectrumData = null;
overviewWaterfallRows = []; overviewWaterfallRows = [];
_wfResetOffscreen();
scheduleOverviewDraw(); scheduleOverviewDraw();
updateRdsPsOverlay(null); updateRdsPsOverlay(null);
clearSpectrumCanvas(); clearSpectrumCanvas();
@@ -301,7 +301,7 @@ pub async fn spectrum(
let context_updates = context.get_ref().clone(); let context_updates = context.get_ref().clone();
let mut last_revision: Option<u64> = None; let mut last_revision: Option<u64> = None;
let updates = let updates =
IntervalStream::new(time::interval(Duration::from_millis(100))).filter_map(move |_| { IntervalStream::new(time::interval(Duration::from_millis(200))).filter_map(move |_| {
let context = context_updates.clone(); let context = context_updates.clone();
std::future::ready({ std::future::ready({
let next = context let next = context