Add real-time WebSocket waterfall with I/Q capture and server-side FFT

Replace the batch rtl_power SSE pipeline with continuous I/Q streaming
via WebSocket for smooth ~25fps waterfall display. The server captures
raw I/Q samples (rtl_sdr/rx_sdr), computes Hann-windowed FFT, and
sends compact binary frames (1035 bytes vs ~15KB JSON, 93% reduction).
Client falls back to existing SSE path if WebSocket is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-08 12:37:50 +00:00
parent 7aae2944d4
commit 026337a350
12 changed files with 1107 additions and 8 deletions

View File

@@ -3095,6 +3095,10 @@ const WATERFALL_ZOOM_MIN_MHZ = 0.1;
const WATERFALL_ZOOM_MAX_MHZ = 500;
const WATERFALL_DEFAULT_SPAN_MHZ = 2.0;
// WebSocket waterfall state
let waterfallWebSocket = null;
let waterfallUseWebSocket = false;
function resizeCanvasToDisplaySize(canvas) {
if (!canvas) return false;
const dpr = window.devicePixelRatio || 1;
@@ -3525,11 +3529,199 @@ function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) {
spectrumCtx.fill();
}
function connectWaterfallWebSocket(config) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/waterfall`;
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
const timeout = setTimeout(() => {
ws.close();
reject(new Error('WebSocket connection timeout'));
}, 5000);
ws.onopen = () => {
clearTimeout(timeout);
ws.send(JSON.stringify({ cmd: 'start', ...config }));
};
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
if (msg.status === 'started') {
waterfallWebSocket = ws;
waterfallUseWebSocket = true;
if (typeof msg.start_freq === 'number') waterfallStartFreq = msg.start_freq;
if (typeof msg.end_freq === 'number') waterfallEndFreq = msg.end_freq;
const rangeLabel = document.getElementById('waterfallFreqRange');
if (rangeLabel) {
rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`;
}
updateWaterfallZoomLabel(waterfallStartFreq, waterfallEndFreq);
resolve(ws);
} else if (msg.status === 'error') {
ws.close();
reject(new Error(msg.message || 'WebSocket waterfall error'));
} else if (msg.status === 'stopped') {
// Server confirmed stop
}
} else if (event.data instanceof ArrayBuffer) {
const now = Date.now();
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
lastWaterfallDraw = now;
parseBinaryWaterfallFrame(event.data);
}
};
ws.onerror = () => {
clearTimeout(timeout);
reject(new Error('WebSocket connection failed'));
};
ws.onclose = () => {
if (waterfallUseWebSocket && isWaterfallRunning) {
waterfallWebSocket = null;
waterfallUseWebSocket = false;
isWaterfallRunning = false;
setWaterfallControlButtons(false);
if (typeof releaseDevice === 'function') {
releaseDevice('waterfall');
}
}
};
} catch (e) {
reject(e);
}
});
}
function parseBinaryWaterfallFrame(buffer) {
if (buffer.byteLength < 11) return;
const view = new DataView(buffer);
const msgType = view.getUint8(0);
if (msgType !== 0x01) return;
const startFreq = view.getFloat32(1, true);
const endFreq = view.getFloat32(5, true);
const binCount = view.getUint16(9, true);
if (buffer.byteLength < 11 + binCount) return;
const bins = new Uint8Array(buffer, 11, binCount);
waterfallStartFreq = startFreq;
waterfallEndFreq = endFreq;
const rangeLabel = document.getElementById('waterfallFreqRange');
if (rangeLabel) {
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
}
updateWaterfallZoomLabel(startFreq, endFreq);
drawWaterfallRowBinary(bins);
drawSpectrumLineBinary(bins, startFreq, endFreq);
}
function drawWaterfallRowBinary(bins) {
if (!waterfallCtx || !waterfallCanvas) return;
const w = waterfallCanvas.width;
const h = waterfallCanvas.height;
const rowHeight = waterfallRowImage ? waterfallRowImage.height : 1;
// Scroll existing content down
waterfallCtx.drawImage(waterfallCanvas, 0, 0, w, h - rowHeight, 0, rowHeight, w, h - rowHeight);
if (!waterfallRowImage || waterfallRowImage.width !== w || waterfallRowImage.height !== rowHeight) {
waterfallRowImage = waterfallCtx.createImageData(w, rowHeight);
}
const rowData = waterfallRowImage.data;
const palette = waterfallPalette || buildWaterfallPalette();
const binCount = bins.length;
for (let x = 0; x < w; x++) {
const pos = (x / (w - 1)) * (binCount - 1);
const i0 = Math.floor(pos);
const i1 = Math.min(binCount - 1, i0 + 1);
const t = pos - i0;
// Interpolate between bins (already uint8, 0-255)
const val = Math.round(bins[i0] * (1 - t) + bins[i1] * t);
const color = palette[Math.max(0, Math.min(255, val))] || [0, 0, 0];
for (let y = 0; y < rowHeight; y++) {
const offset = (y * w + x) * 4;
rowData[offset] = color[0];
rowData[offset + 1] = color[1];
rowData[offset + 2] = color[2];
rowData[offset + 3] = 255;
}
}
waterfallCtx.putImageData(waterfallRowImage, 0, 0);
}
function drawSpectrumLineBinary(bins, startFreq, endFreq) {
if (!spectrumCtx || !spectrumCanvas) return;
const w = spectrumCanvas.width;
const h = spectrumCanvas.height;
spectrumCtx.clearRect(0, 0, w, h);
// Background
spectrumCtx.fillStyle = 'rgba(0, 0, 0, 0.8)';
spectrumCtx.fillRect(0, 0, w, h);
// Grid lines
spectrumCtx.strokeStyle = 'rgba(0, 200, 255, 0.1)';
spectrumCtx.lineWidth = 0.5;
for (let i = 0; i < 5; i++) {
const y = (h / 5) * i;
spectrumCtx.beginPath();
spectrumCtx.moveTo(0, y);
spectrumCtx.lineTo(w, y);
spectrumCtx.stroke();
}
// Frequency labels
const dpr = window.devicePixelRatio || 1;
spectrumCtx.fillStyle = 'rgba(0, 200, 255, 0.5)';
spectrumCtx.font = `${9 * dpr}px monospace`;
const freqRange = endFreq - startFreq;
for (let i = 0; i <= 4; i++) {
const freq = startFreq + (freqRange / 4) * i;
const x = (w / 4) * i;
spectrumCtx.fillText(freq.toFixed(1), x + 2, h - 2);
}
if (bins.length === 0) return;
// Draw spectrum line — bins are pre-quantized 0-255
spectrumCtx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
spectrumCtx.lineWidth = 1.5;
spectrumCtx.beginPath();
for (let i = 0; i < bins.length; i++) {
const x = (i / (bins.length - 1)) * w;
const normalized = bins[i] / 255;
const y = h - 12 - normalized * (h - 16);
if (i === 0) spectrumCtx.moveTo(x, y);
else spectrumCtx.lineTo(x, y);
}
spectrumCtx.stroke();
// Fill under line
const lastX = w;
const lastY = h - 12 - (bins[bins.length - 1] / 255) * (h - 16);
spectrumCtx.lineTo(lastX, h);
spectrumCtx.lineTo(0, h);
spectrumCtx.closePath();
spectrumCtx.fillStyle = 'rgba(0, 255, 255, 0.08)';
spectrumCtx.fill();
}
async function startWaterfall(options = {}) {
const { silent = false, resume = false } = options;
const startFreq = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
const endFreq = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
const binSize = parseInt(document.getElementById('waterfallBinSize')?.value || 10000);
const fftSize = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
const gain = parseInt(document.getElementById('waterfallGain')?.value || 40);
const device = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
initWaterfallCanvas();
@@ -3565,10 +3757,51 @@ async function startWaterfall(options = {}) {
}
setWaterfallMode('rf');
const spanMhz = Math.max(0.1, waterfallEndFreq - waterfallStartFreq);
// Try WebSocket path first (I/Q + server-side FFT)
const centerFreq = (startFreq + endFreq) / 2;
const spanMhz = Math.max(0.1, endFreq - startFreq);
try {
const wsConfig = {
center_freq: centerFreq,
span_mhz: spanMhz,
gain: gain,
device: device,
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
fft_size: fftSize,
fps: 25,
avg_count: 4,
};
await connectWaterfallWebSocket(wsConfig);
isWaterfallRunning = true;
setWaterfallControlButtons(true);
const waterfallPanel = document.getElementById('waterfallPanel');
if (waterfallPanel) waterfallPanel.style.display = 'block';
lastWaterfallDraw = 0;
initWaterfallCanvas();
if (typeof reserveDevice === 'function') {
reserveDevice(parseInt(device), 'waterfall');
}
if (resume || resumeRfWaterfallAfterListening) {
resumeRfWaterfallAfterListening = false;
}
if (waterfallResumeTimer) {
clearTimeout(waterfallResumeTimer);
waterfallResumeTimer = null;
}
console.log('[WATERFALL] WebSocket connected');
return { started: true };
} catch (wsErr) {
console.log('[WATERFALL] WebSocket unavailable, falling back to SSE:', wsErr.message);
}
// Fallback: SSE / rtl_power path
const segments = Math.max(1, Math.ceil(spanMhz / 2.4));
const targetSweepSeconds = 0.8;
const interval = Math.max(0.1, Math.min(0.3, targetSweepSeconds / segments));
const binSize = fftSize;
try {
const response = await fetch('/listening/waterfall/start', {
@@ -3635,6 +3868,27 @@ async function stopWaterfall() {
return;
}
// WebSocket path
if (waterfallUseWebSocket && waterfallWebSocket) {
try {
if (waterfallWebSocket.readyState === WebSocket.OPEN) {
waterfallWebSocket.send(JSON.stringify({ cmd: 'stop' }));
}
waterfallWebSocket.close();
} catch (e) {
console.error('[WATERFALL] WebSocket stop error:', e);
}
waterfallWebSocket = null;
waterfallUseWebSocket = false;
isWaterfallRunning = false;
setWaterfallControlButtons(false);
if (typeof releaseDevice === 'function') {
releaseDevice('waterfall');
}
return;
}
// SSE fallback path
try {
await fetch('/listening/waterfall/stop', { method: 'POST' });
isWaterfallRunning = false;