[feat](trx-frontend-http): encode spectrum bins as compact i8/base64 SSE

Replace the JSON f32 array (~7.5 KB/frame) with a named SSE event "b"
carrying base64-encoded i8 bins (~1.4 KB/frame, ~5x reduction):

  event: b
  data: {center_hz},{sample_rate},{base64_i8_bins}

1 dB per step covers the -128…+127 dBFS display range, sufficient for
visualization. RDS is stripped from the spectrum frame and emitted as a
separate named "event: rds" only when the payload changes. The JS
decoder uses atob() + sign-extension to reconstruct the float bin array.
A minimal inline base64 encoder is added server-side (no new crate).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-09 21:34:11 +01:00
parent 1d8b77ae44
commit 75355c75a5
2 changed files with 90 additions and 17 deletions
@@ -6396,6 +6396,7 @@ function scheduleSpectrumReconnect() {
function startSpectrumStreaming() {
if (spectrumSource !== null) return;
spectrumSource = new EventSource("/spectrum");
// Unnamed event = reset signal.
spectrumSource.onmessage = (evt) => {
if (evt.data === "null") {
rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream reset"));
@@ -6408,25 +6409,45 @@ function startSpectrumStreaming() {
scheduleOverviewDraw();
clearSpectrumCanvas();
updateRdsPsOverlay(null);
return;
}
};
// Named "b" event = compact binary frame: "{center_hz},{sample_rate},{base64_i8_bins}"
// Bins are i8 (1 dB/step), base64-encoded for ~5× size reduction vs JSON f32 array.
// Named "b" event = compact binary frame: "{center_hz},{sample_rate},{base64_i8_bins}"
// Bins are i8 (1 dB/step), base64-encoded for ~5× size reduction vs JSON f32 array.
spectrumSource.addEventListener("b", (evt) => {
try {
lastSpectrumData = JSON.parse(evt.data);
const commaA = evt.data.indexOf(",");
const commaB = evt.data.indexOf(",", commaA + 1);
const centerHz = Number(evt.data.slice(0, commaA));
const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
const b64 = evt.data.slice(commaB + 1);
const raw = atob(b64);
const bins = new Array(raw.length);
for (let i = 0; i < raw.length; i++) bins[i] = (raw.charCodeAt(i) << 24 >> 24);
// Preserve any RDS data from the last rds event.
const rds = lastSpectrumData?.rds;
lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
window.lastSpectrumData = lastSpectrumData;
lastSpectrumRenderData = buildSpectrumRenderData(lastSpectrumData);
settlePendingSpectrumFrameWaiters(lastSpectrumData);
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
pushOverviewWaterfallFrame(lastSpectrumData);
refreshCenterFreqDisplay();
if (window.refreshCwTonePicker) {
window.refreshCwTonePicker();
}
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
scheduleSpectrumDraw();
if (lastModeName === "WFM") {
updateRdsPsOverlay(lastSpectrumData.rds);
}
if (lastModeName === "WFM") updateRdsPsOverlay(lastSpectrumData.rds);
} catch (_) {}
};
});
// Named "rds" event = RDS metadata changed (emitted only when it changes).
spectrumSource.addEventListener("rds", (evt) => {
try {
const rds = evt.data === "null" ? undefined : JSON.parse(evt.data);
if (lastSpectrumData) lastSpectrumData.rds = rds;
if (lastModeName === "WFM") updateRdsPsOverlay(rds ?? null);
updateDocumentTitle(rds ?? null);
} catch (_) {}
});
spectrumSource.onerror = () => {
rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected"));
if (spectrumSource) {