mirror of
https://github.com/smittix/intercept.git
synced 2026-04-29 17:19:59 -07:00
Merge upstream/main: add gsm_spy blueprint
This commit is contained in:
@@ -69,6 +69,24 @@ const scannerPresets = {
|
||||
amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Suggest the appropriate modulation for a given frequency (in MHz).
|
||||
* Uses standard band allocations to pick AM, NFM, WFM, or USB.
|
||||
*/
|
||||
function suggestModulation(freqMhz) {
|
||||
if (freqMhz < 0.52) return 'am'; // LW/MW AM broadcast
|
||||
if (freqMhz < 1.7) return 'am'; // MW AM broadcast
|
||||
if (freqMhz < 30) return 'usb'; // HF/Shortwave
|
||||
if (freqMhz < 88) return 'fm'; // VHF Low (public safety)
|
||||
if (freqMhz < 108) return 'wfm'; // FM Broadcast
|
||||
if (freqMhz < 137) return 'am'; // Airband
|
||||
if (freqMhz < 174) return 'fm'; // VHF marine, 2m ham, pagers
|
||||
if (freqMhz < 216) return 'wfm'; // VHF TV/DAB
|
||||
if (freqMhz < 470) return 'fm'; // UHF various, 70cm, business/GMRS
|
||||
if (freqMhz < 960) return 'wfm'; // UHF TV
|
||||
return 'am'; // Microwave/ADS-B
|
||||
}
|
||||
|
||||
const audioPresets = {
|
||||
fm: { freq: 98.1, mod: 'wfm' },
|
||||
airband: { freq: 121.5, mod: 'am' }, // Emergency/guard frequency
|
||||
@@ -1886,6 +1904,8 @@ function initListeningPost() {
|
||||
// Connect radio knobs to scanner controls
|
||||
initRadioKnobControls();
|
||||
|
||||
initWaterfallZoomControls();
|
||||
|
||||
// Step dropdown - sync with scanner when changed
|
||||
const stepSelect = document.getElementById('radioScanStep');
|
||||
if (stepSelect) {
|
||||
@@ -2312,8 +2332,7 @@ async function _startDirectListenInternal() {
|
||||
isDirectListening = false;
|
||||
updateDirectListenUI(false);
|
||||
if (resumeRfWaterfallAfterListening) {
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
setTimeout(() => startWaterfall(), 200);
|
||||
scheduleWaterfallResume();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -2366,8 +2385,7 @@ async function _startDirectListenInternal() {
|
||||
isWaterfallRunning = true;
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
setWaterfallControlButtons(true);
|
||||
startAudioWaterfall();
|
||||
}
|
||||
updateDirectListenUI(true, freq);
|
||||
@@ -2379,8 +2397,7 @@ async function _startDirectListenInternal() {
|
||||
isDirectListening = false;
|
||||
updateDirectListenUI(false);
|
||||
if (resumeRfWaterfallAfterListening) {
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
setTimeout(() => startWaterfall(), 200);
|
||||
scheduleWaterfallResume();
|
||||
}
|
||||
} finally {
|
||||
isRestarting = false;
|
||||
@@ -2537,7 +2554,7 @@ async function startWebSocketListen(config, audioPlayer) {
|
||||
/**
|
||||
* Stop direct listening
|
||||
*/
|
||||
function stopDirectListen() {
|
||||
async function stopDirectListen() {
|
||||
console.log('[LISTEN] Stopping');
|
||||
|
||||
// Clear all pending state
|
||||
@@ -2572,7 +2589,7 @@ function stopDirectListen() {
|
||||
}
|
||||
|
||||
// Also stop via HTTP (fallback)
|
||||
fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {});
|
||||
const audioStopPromise = fetch('/listening/audio/stop', { method: 'POST' }).catch(() => {});
|
||||
|
||||
isDirectListening = false;
|
||||
currentSignalLevel = 0;
|
||||
@@ -2584,13 +2601,16 @@ function stopDirectListen() {
|
||||
}
|
||||
|
||||
if (resumeRfWaterfallAfterListening) {
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
isWaterfallRunning = false;
|
||||
setTimeout(() => startWaterfall(), 200);
|
||||
setWaterfallControlButtons(false);
|
||||
await Promise.race([
|
||||
audioStopPromise,
|
||||
new Promise(resolve => setTimeout(resolve, 400))
|
||||
]);
|
||||
scheduleWaterfallResume();
|
||||
} else if (waterfallMode === 'audio' && isWaterfallRunning) {
|
||||
isWaterfallRunning = false;
|
||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||
setWaterfallControlButtons(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3067,6 +3087,17 @@ let waterfallMode = 'rf';
|
||||
let audioWaterfallAnimId = null;
|
||||
let lastAudioWaterfallDraw = 0;
|
||||
let resumeRfWaterfallAfterListening = false;
|
||||
let waterfallResumeTimer = null;
|
||||
let waterfallResumeAttempts = 0;
|
||||
const WATERFALL_RESUME_MAX_ATTEMPTS = 8;
|
||||
const WATERFALL_RESUME_RETRY_MS = 350;
|
||||
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;
|
||||
@@ -3137,6 +3168,214 @@ function initWaterfallCanvas() {
|
||||
}
|
||||
}
|
||||
|
||||
function setWaterfallControlButtons(running) {
|
||||
const startBtn = document.getElementById('startWaterfallBtn');
|
||||
const stopBtn = document.getElementById('stopWaterfallBtn');
|
||||
if (!startBtn || !stopBtn) return;
|
||||
startBtn.style.display = running ? 'none' : 'inline-block';
|
||||
stopBtn.style.display = running ? 'inline-block' : 'none';
|
||||
const dot = document.getElementById('waterfallStripDot');
|
||||
if (dot) {
|
||||
dot.className = running ? 'status-dot sweeping' : 'status-dot inactive';
|
||||
}
|
||||
}
|
||||
|
||||
function getWaterfallRangeFromInputs() {
|
||||
const startInput = document.getElementById('waterfallStartFreq');
|
||||
const endInput = document.getElementById('waterfallEndFreq');
|
||||
const startVal = parseFloat(startInput?.value);
|
||||
const endVal = parseFloat(endInput?.value);
|
||||
const start = Number.isFinite(startVal) ? startVal : waterfallStartFreq;
|
||||
const end = Number.isFinite(endVal) ? endVal : waterfallEndFreq;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function updateWaterfallZoomLabel(start, end) {
|
||||
const label = document.getElementById('waterfallZoomSpan');
|
||||
if (!label) return;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return;
|
||||
const span = Math.max(0, end - start);
|
||||
if (span >= 1) {
|
||||
label.textContent = `${span.toFixed(1)} MHz`;
|
||||
} else {
|
||||
label.textContent = `${Math.round(span * 1000)} kHz`;
|
||||
}
|
||||
}
|
||||
|
||||
function setWaterfallRange(center, span) {
|
||||
if (!Number.isFinite(center) || !Number.isFinite(span)) return;
|
||||
const clampedSpan = Math.max(WATERFALL_ZOOM_MIN_MHZ, Math.min(WATERFALL_ZOOM_MAX_MHZ, span));
|
||||
const half = clampedSpan / 2;
|
||||
let start = center - half;
|
||||
let end = center + half;
|
||||
const minFreq = 0.01;
|
||||
if (start < minFreq) {
|
||||
end += (minFreq - start);
|
||||
start = minFreq;
|
||||
}
|
||||
if (end <= start) {
|
||||
end = start + WATERFALL_ZOOM_MIN_MHZ;
|
||||
}
|
||||
|
||||
waterfallStartFreq = start;
|
||||
waterfallEndFreq = end;
|
||||
|
||||
const startInput = document.getElementById('waterfallStartFreq');
|
||||
const endInput = document.getElementById('waterfallEndFreq');
|
||||
if (startInput) startInput.value = start.toFixed(3);
|
||||
if (endInput) endInput.value = end.toFixed(3);
|
||||
|
||||
const rangeLabel = document.getElementById('waterfallFreqRange');
|
||||
if (rangeLabel && !isWaterfallRunning) {
|
||||
rangeLabel.textContent = `${start.toFixed(1)} - ${end.toFixed(1)} MHz`;
|
||||
}
|
||||
updateWaterfallZoomLabel(start, end);
|
||||
}
|
||||
|
||||
function getWaterfallCenterForZoom(start, end) {
|
||||
const tuned = parseFloat(document.getElementById('radioScanStart')?.value || '');
|
||||
if (Number.isFinite(tuned) && tuned > 0) return tuned;
|
||||
return (start + end) / 2;
|
||||
}
|
||||
|
||||
async function syncWaterfallToFrequency(freq, options = {}) {
|
||||
const { autoStart = false, restartIfRunning = true, silent = true } = options;
|
||||
const numericFreq = parseFloat(freq);
|
||||
if (!Number.isFinite(numericFreq) || numericFreq <= 0) return { started: false };
|
||||
|
||||
const { start, end } = getWaterfallRangeFromInputs();
|
||||
const span = (Number.isFinite(start) && Number.isFinite(end) && end > start)
|
||||
? (end - start)
|
||||
: WATERFALL_DEFAULT_SPAN_MHZ;
|
||||
|
||||
setWaterfallRange(numericFreq, span);
|
||||
|
||||
if (!autoStart) return { started: false };
|
||||
if (isDirectListening || waterfallMode === 'audio') return { started: false };
|
||||
|
||||
if (isWaterfallRunning && waterfallMode === 'rf' && restartIfRunning) {
|
||||
// Reuse existing WebSocket to avoid USB device release race
|
||||
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
|
||||
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
waterfallWebSocket.send(JSON.stringify({
|
||||
cmd: 'start',
|
||||
center_freq: (sf + ef) / 2,
|
||||
span_mhz: Math.max(0.1, ef - sf),
|
||||
gain: g,
|
||||
device: dev,
|
||||
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
||||
fft_size: fft,
|
||||
fps: 25,
|
||||
avg_count: 4,
|
||||
}));
|
||||
return { started: true };
|
||||
}
|
||||
await stopWaterfall();
|
||||
return await startWaterfall({ silent: silent });
|
||||
}
|
||||
|
||||
if (!isWaterfallRunning) {
|
||||
return await startWaterfall({ silent: silent });
|
||||
}
|
||||
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
async function zoomWaterfall(direction) {
|
||||
const { start, end } = getWaterfallRangeFromInputs();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
|
||||
|
||||
const zoomIn = direction === 'in' || direction === '+';
|
||||
const zoomOut = direction === 'out' || direction === '-';
|
||||
if (!zoomIn && !zoomOut) return;
|
||||
|
||||
const span = end - start;
|
||||
const newSpan = zoomIn ? span / 2 : span * 2;
|
||||
const center = getWaterfallCenterForZoom(start, end);
|
||||
setWaterfallRange(center, newSpan);
|
||||
|
||||
if (isWaterfallRunning && waterfallMode === 'rf' && !isDirectListening) {
|
||||
// Reuse existing WebSocket to avoid USB device release race
|
||||
if (waterfallUseWebSocket && waterfallWebSocket && waterfallWebSocket.readyState === WebSocket.OPEN) {
|
||||
const sf = parseFloat(document.getElementById('waterfallStartFreq')?.value || 88);
|
||||
const ef = parseFloat(document.getElementById('waterfallEndFreq')?.value || 108);
|
||||
const fft = parseInt(document.getElementById('waterfallFftSize')?.value || document.getElementById('waterfallBinSize')?.value || 1024);
|
||||
const g = parseInt(document.getElementById('waterfallGain')?.value || 40);
|
||||
const dev = typeof getSelectedDevice === 'function' ? getSelectedDevice() : 0;
|
||||
waterfallWebSocket.send(JSON.stringify({
|
||||
cmd: 'start',
|
||||
center_freq: (sf + ef) / 2,
|
||||
span_mhz: Math.max(0.1, ef - sf),
|
||||
gain: g,
|
||||
device: dev,
|
||||
sdr_type: (typeof getSelectedSdrType === 'function') ? getSelectedSdrType() : 'rtlsdr',
|
||||
fft_size: fft,
|
||||
fps: 25,
|
||||
avg_count: 4,
|
||||
}));
|
||||
} else {
|
||||
await stopWaterfall();
|
||||
await startWaterfall({ silent: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initWaterfallZoomControls() {
|
||||
const startInput = document.getElementById('waterfallStartFreq');
|
||||
const endInput = document.getElementById('waterfallEndFreq');
|
||||
if (!startInput && !endInput) return;
|
||||
|
||||
const sync = () => {
|
||||
const { start, end } = getWaterfallRangeFromInputs();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
|
||||
waterfallStartFreq = start;
|
||||
waterfallEndFreq = end;
|
||||
updateWaterfallZoomLabel(start, end);
|
||||
};
|
||||
|
||||
if (startInput) startInput.addEventListener('input', sync);
|
||||
if (endInput) endInput.addEventListener('input', sync);
|
||||
sync();
|
||||
}
|
||||
|
||||
function scheduleWaterfallResume() {
|
||||
if (!resumeRfWaterfallAfterListening) return;
|
||||
if (waterfallResumeTimer) {
|
||||
clearTimeout(waterfallResumeTimer);
|
||||
waterfallResumeTimer = null;
|
||||
}
|
||||
waterfallResumeAttempts = 0;
|
||||
waterfallResumeTimer = setTimeout(attemptWaterfallResume, 200);
|
||||
}
|
||||
|
||||
async function attemptWaterfallResume() {
|
||||
if (!resumeRfWaterfallAfterListening) return;
|
||||
if (isDirectListening) {
|
||||
waterfallResumeTimer = setTimeout(attemptWaterfallResume, WATERFALL_RESUME_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await startWaterfall({ silent: true, resume: true });
|
||||
if (result && result.started) {
|
||||
waterfallResumeTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const retryable = result ? result.retryable : true;
|
||||
if (retryable && waterfallResumeAttempts < WATERFALL_RESUME_MAX_ATTEMPTS) {
|
||||
waterfallResumeAttempts += 1;
|
||||
waterfallResumeTimer = setTimeout(attemptWaterfallResume, WATERFALL_RESUME_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
waterfallResumeTimer = null;
|
||||
}
|
||||
|
||||
function setWaterfallMode(mode) {
|
||||
waterfallMode = mode;
|
||||
const header = document.getElementById('waterfallFreqRange');
|
||||
@@ -3334,18 +3573,209 @@ function drawSpectrumLine(bins, startFreq, endFreq, labelUnit) {
|
||||
spectrumCtx.fill();
|
||||
}
|
||||
|
||||
function startWaterfall() {
|
||||
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();
|
||||
const maxBins = Math.min(4096, Math.max(128, waterfallCanvas ? waterfallCanvas.width : 800));
|
||||
|
||||
if (startFreq >= endFreq) {
|
||||
if (typeof showNotification === 'function') showNotification('Error', 'End frequency must be greater than start');
|
||||
return;
|
||||
if (!silent && typeof showNotification === 'function') {
|
||||
showNotification('Error', 'End frequency must be greater than start');
|
||||
}
|
||||
return { started: false, retryable: false };
|
||||
}
|
||||
|
||||
waterfallStartFreq = startFreq;
|
||||
@@ -3354,69 +3784,165 @@ function startWaterfall() {
|
||||
if (rangeLabel) {
|
||||
rangeLabel.textContent = `${startFreq.toFixed(1)} - ${endFreq.toFixed(1)} MHz`;
|
||||
}
|
||||
updateWaterfallZoomLabel(startFreq, endFreq);
|
||||
|
||||
if (isDirectListening) {
|
||||
if (isDirectListening && !resume) {
|
||||
isWaterfallRunning = true;
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
setWaterfallControlButtons(true);
|
||||
startAudioWaterfall();
|
||||
return;
|
||||
resumeRfWaterfallAfterListening = true;
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
if (isDirectListening && resume) {
|
||||
return { started: false, retryable: true };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fetch('/listening/waterfall/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
start_freq: startFreq,
|
||||
end_freq: endFreq,
|
||||
bin_size: binSize,
|
||||
gain: gain,
|
||||
device: device,
|
||||
max_bins: maxBins,
|
||||
interval: interval,
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isWaterfallRunning = true;
|
||||
document.getElementById('startWaterfallBtn').style.display = 'none';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'block';
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
lastWaterfallDraw = 0;
|
||||
initWaterfallCanvas();
|
||||
connectWaterfallSSE();
|
||||
} else {
|
||||
if (typeof showNotification === 'function') showNotification('Error', data.message || 'Failed to start waterfall');
|
||||
try {
|
||||
const response = await fetch('/listening/waterfall/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
start_freq: startFreq,
|
||||
end_freq: endFreq,
|
||||
bin_size: binSize,
|
||||
gain: gain,
|
||||
device: device,
|
||||
max_bins: maxBins,
|
||||
interval: interval,
|
||||
})
|
||||
});
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {}
|
||||
|
||||
if (!response.ok || data.status !== 'started') {
|
||||
if (!silent && typeof showNotification === 'function') {
|
||||
showNotification('Error', data.message || 'Failed to start waterfall');
|
||||
}
|
||||
return {
|
||||
started: false,
|
||||
retryable: response.status === 409 || data.error_type === 'DEVICE_BUSY'
|
||||
};
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[WATERFALL] Start error:', err));
|
||||
|
||||
isWaterfallRunning = true;
|
||||
setWaterfallControlButtons(true);
|
||||
const waterfallPanel = document.getElementById('waterfallPanel');
|
||||
if (waterfallPanel) waterfallPanel.style.display = 'block';
|
||||
lastWaterfallDraw = 0;
|
||||
initWaterfallCanvas();
|
||||
connectWaterfallSSE();
|
||||
if (typeof reserveDevice === 'function') {
|
||||
reserveDevice(parseInt(device), 'waterfall');
|
||||
}
|
||||
if (resume || resumeRfWaterfallAfterListening) {
|
||||
resumeRfWaterfallAfterListening = false;
|
||||
}
|
||||
if (waterfallResumeTimer) {
|
||||
clearTimeout(waterfallResumeTimer);
|
||||
waterfallResumeTimer = null;
|
||||
}
|
||||
return { started: true };
|
||||
} catch (err) {
|
||||
console.error('[WATERFALL] Start error:', err);
|
||||
if (!silent && typeof showNotification === 'function') {
|
||||
showNotification('Error', 'Failed to start waterfall');
|
||||
}
|
||||
return { started: false, retryable: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function stopWaterfall() {
|
||||
if (waterfallMode === 'audio') {
|
||||
stopAudioWaterfall();
|
||||
isWaterfallRunning = false;
|
||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||
setWaterfallControlButtons(false);
|
||||
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');
|
||||
}
|
||||
// Allow backend WebSocket handler to finish cleanup and release SDR
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE fallback path
|
||||
try {
|
||||
await fetch('/listening/waterfall/stop', { method: 'POST' });
|
||||
isWaterfallRunning = false;
|
||||
if (waterfallEventSource) { waterfallEventSource.close(); waterfallEventSource = null; }
|
||||
document.getElementById('startWaterfallBtn').style.display = 'block';
|
||||
document.getElementById('stopWaterfallBtn').style.display = 'none';
|
||||
setWaterfallControlButtons(false);
|
||||
if (typeof releaseDevice === 'function') {
|
||||
releaseDevice('waterfall');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WATERFALL] Stop error:', err);
|
||||
}
|
||||
@@ -3436,6 +3962,7 @@ function connectWaterfallSSE() {
|
||||
if (rangeLabel) {
|
||||
rangeLabel.textContent = `${waterfallStartFreq.toFixed(1)} - ${waterfallEndFreq.toFixed(1)} MHz`;
|
||||
}
|
||||
updateWaterfallZoomLabel(waterfallStartFreq, waterfallEndFreq);
|
||||
const now = Date.now();
|
||||
if (now - lastWaterfallDraw < WATERFALL_MIN_INTERVAL_MS) return;
|
||||
lastWaterfallDraw = now;
|
||||
@@ -3462,17 +3989,51 @@ function bindWaterfallInteraction() {
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
||||
if (typeof tuneToFrequency === 'function') {
|
||||
tuneToFrequency(freq, typeof currentModulation !== 'undefined' ? currentModulation : undefined);
|
||||
tuneToFrequency(freq, suggestModulation(freq));
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltip for showing frequency + modulation on hover
|
||||
let tooltip = document.getElementById('waterfallTooltip');
|
||||
if (!tooltip) {
|
||||
tooltip = document.createElement('div');
|
||||
tooltip.id = 'waterfallTooltip';
|
||||
tooltip.style.cssText = 'position:fixed;pointer-events:none;background:rgba(0,0,0,0.85);color:#0f0;padding:4px 8px;border-radius:4px;font-size:12px;font-family:monospace;z-index:9999;display:none;white-space:nowrap;border:1px solid #333;';
|
||||
document.body.appendChild(tooltip);
|
||||
}
|
||||
|
||||
const hoverHandler = (event) => {
|
||||
if (waterfallMode === 'audio') {
|
||||
tooltip.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const canvas = event.currentTarget;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const ratio = Math.max(0, Math.min(1, x / rect.width));
|
||||
const freq = waterfallStartFreq + ratio * (waterfallEndFreq - waterfallStartFreq);
|
||||
const mod = suggestModulation(freq);
|
||||
tooltip.textContent = `${freq.toFixed(3)} MHz \u00b7 ${mod.toUpperCase()}`;
|
||||
tooltip.style.left = (event.clientX + 12) + 'px';
|
||||
tooltip.style.top = (event.clientY - 28) + 'px';
|
||||
tooltip.style.display = 'block';
|
||||
};
|
||||
|
||||
const leaveHandler = () => {
|
||||
tooltip.style.display = 'none';
|
||||
};
|
||||
|
||||
if (waterfallCanvas) {
|
||||
waterfallCanvas.style.cursor = 'crosshair';
|
||||
waterfallCanvas.addEventListener('click', handler);
|
||||
waterfallCanvas.addEventListener('mousemove', hoverHandler);
|
||||
waterfallCanvas.addEventListener('mouseleave', leaveHandler);
|
||||
}
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.style.cursor = 'crosshair';
|
||||
spectrumCanvas.addEventListener('click', handler);
|
||||
spectrumCanvas.addEventListener('mousemove', hoverHandler);
|
||||
spectrumCanvas.addEventListener('mouseleave', leaveHandler);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3497,3 +4058,5 @@ window.manualSignalGuess = manualSignalGuess;
|
||||
window.guessSignal = guessSignal;
|
||||
window.startWaterfall = startWaterfall;
|
||||
window.stopWaterfall = stopWaterfall;
|
||||
window.zoomWaterfall = zoomWaterfall;
|
||||
window.syncWaterfallToFrequency = syncWaterfallToFrequency;
|
||||
|
||||
@@ -11,6 +11,18 @@ const SSTVGeneral = (function() {
|
||||
let currentMode = null;
|
||||
let progress = 0;
|
||||
|
||||
// Signal scope state
|
||||
let sstvGeneralScopeCtx = null;
|
||||
let sstvGeneralScopeAnim = null;
|
||||
let sstvGeneralScopeHistory = [];
|
||||
const SSTV_GENERAL_SCOPE_LEN = 200;
|
||||
let sstvGeneralScopeRms = 0;
|
||||
let sstvGeneralScopePeak = 0;
|
||||
let sstvGeneralScopeTargetRms = 0;
|
||||
let sstvGeneralScopeTargetPeak = 0;
|
||||
let sstvGeneralScopeMsgBurst = 0;
|
||||
let sstvGeneralScopeTone = null;
|
||||
|
||||
/**
|
||||
* Initialize the SSTV General mode
|
||||
*/
|
||||
@@ -190,6 +202,136 @@ const SSTVGeneral = (function() {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize signal scope canvas
|
||||
*/
|
||||
function initSstvGeneralScope() {
|
||||
const canvas = document.getElementById('sstvGeneralScopeCanvas');
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||
sstvGeneralScopeCtx = canvas.getContext('2d');
|
||||
sstvGeneralScopeHistory = new Array(SSTV_GENERAL_SCOPE_LEN).fill(0);
|
||||
sstvGeneralScopeRms = 0;
|
||||
sstvGeneralScopePeak = 0;
|
||||
sstvGeneralScopeTargetRms = 0;
|
||||
sstvGeneralScopeTargetPeak = 0;
|
||||
sstvGeneralScopeMsgBurst = 0;
|
||||
sstvGeneralScopeTone = null;
|
||||
drawSstvGeneralScope();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw signal scope animation frame
|
||||
*/
|
||||
function drawSstvGeneralScope() {
|
||||
const ctx = sstvGeneralScopeCtx;
|
||||
if (!ctx) return;
|
||||
const W = ctx.canvas.width;
|
||||
const H = ctx.canvas.height;
|
||||
const midY = H / 2;
|
||||
|
||||
// Phosphor persistence
|
||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Smooth towards target
|
||||
sstvGeneralScopeRms += (sstvGeneralScopeTargetRms - sstvGeneralScopeRms) * 0.25;
|
||||
sstvGeneralScopePeak += (sstvGeneralScopeTargetPeak - sstvGeneralScopePeak) * 0.15;
|
||||
|
||||
// Push to history
|
||||
sstvGeneralScopeHistory.push(Math.min(sstvGeneralScopeRms / 32768, 1.0));
|
||||
if (sstvGeneralScopeHistory.length > SSTV_GENERAL_SCOPE_LEN) sstvGeneralScopeHistory.shift();
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = (H / 4) * i;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
}
|
||||
for (let i = 1; i < 8; i++) {
|
||||
const x = (W / 8) * i;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||
}
|
||||
|
||||
// Waveform
|
||||
const stepX = W / (SSTV_GENERAL_SCOPE_LEN - 1);
|
||||
ctx.strokeStyle = '#c080ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = '#c080ff';
|
||||
ctx.shadowBlur = 4;
|
||||
|
||||
// Upper half
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||
const y = midY - amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Lower half (mirror)
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvGeneralScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvGeneralScopeHistory[i] * midY * 0.9;
|
||||
const y = midY + amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Peak indicator
|
||||
const peakNorm = Math.min(sstvGeneralScopePeak / 32768, 1.0);
|
||||
if (peakNorm > 0.01) {
|
||||
const peakY = midY - peakNorm * midY * 0.9;
|
||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Image decode flash
|
||||
if (sstvGeneralScopeMsgBurst > 0.01) {
|
||||
ctx.fillStyle = `rgba(0, 255, 100, ${sstvGeneralScopeMsgBurst * 0.15})`;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
sstvGeneralScopeMsgBurst *= 0.88;
|
||||
}
|
||||
|
||||
// Update labels
|
||||
const rmsLabel = document.getElementById('sstvGeneralScopeRmsLabel');
|
||||
const peakLabel = document.getElementById('sstvGeneralScopePeakLabel');
|
||||
const toneLabel = document.getElementById('sstvGeneralScopeToneLabel');
|
||||
const statusLabel = document.getElementById('sstvGeneralScopeStatusLabel');
|
||||
if (rmsLabel) rmsLabel.textContent = Math.round(sstvGeneralScopeRms);
|
||||
if (peakLabel) peakLabel.textContent = Math.round(sstvGeneralScopePeak);
|
||||
if (toneLabel) {
|
||||
if (sstvGeneralScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||
else if (sstvGeneralScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||
else if (sstvGeneralScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||
else if (sstvGeneralScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||
}
|
||||
if (statusLabel) {
|
||||
if (sstvGeneralScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||
}
|
||||
|
||||
sstvGeneralScopeAnim = requestAnimationFrame(drawSstvGeneralScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop signal scope
|
||||
*/
|
||||
function stopSstvGeneralScope() {
|
||||
if (sstvGeneralScopeAnim) { cancelAnimationFrame(sstvGeneralScopeAnim); sstvGeneralScopeAnim = null; }
|
||||
sstvGeneralScopeCtx = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSE stream
|
||||
*/
|
||||
@@ -198,6 +340,11 @@ const SSTVGeneral = (function() {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Show and init scope
|
||||
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'block';
|
||||
initSstvGeneralScope();
|
||||
|
||||
eventSource = new EventSource('/sstv-general/stream');
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
@@ -205,6 +352,10 @@ const SSTVGeneral = (function() {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'sstv_progress') {
|
||||
handleProgress(data);
|
||||
} else if (data.type === 'sstv_scope') {
|
||||
sstvGeneralScopeTargetRms = data.rms;
|
||||
sstvGeneralScopeTargetPeak = data.peak;
|
||||
if (data.tone !== undefined) sstvGeneralScopeTone = data.tone;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err);
|
||||
@@ -227,6 +378,9 @@ const SSTVGeneral = (function() {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopSstvGeneralScope();
|
||||
const scopePanel = document.getElementById('sstvGeneralScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +399,7 @@ const SSTVGeneral = (function() {
|
||||
renderGallery();
|
||||
showNotification('SSTV', 'New image decoded!');
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
sstvGeneralScopeMsgBurst = 1.0;
|
||||
// Clear decode progress so signal monitor can take over
|
||||
const liveContent = document.getElementById('sstvGeneralLiveContent');
|
||||
if (liveContent) liveContent.innerHTML = '';
|
||||
|
||||
@@ -21,6 +21,18 @@ const SSTV = (function() {
|
||||
// ISS frequency
|
||||
const ISS_FREQ = 145.800;
|
||||
|
||||
// Signal scope state
|
||||
let sstvScopeCtx = null;
|
||||
let sstvScopeAnim = null;
|
||||
let sstvScopeHistory = [];
|
||||
const SSTV_SCOPE_LEN = 200;
|
||||
let sstvScopeRms = 0;
|
||||
let sstvScopePeak = 0;
|
||||
let sstvScopeTargetRms = 0;
|
||||
let sstvScopeTargetPeak = 0;
|
||||
let sstvScopeMsgBurst = 0;
|
||||
let sstvScopeTone = null;
|
||||
|
||||
/**
|
||||
* Initialize the SSTV mode
|
||||
*/
|
||||
@@ -634,6 +646,136 @@ const SSTV = (function() {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize signal scope canvas
|
||||
*/
|
||||
function initSstvScope() {
|
||||
const canvas = document.getElementById('sstvScopeCanvas');
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * (window.devicePixelRatio || 1);
|
||||
canvas.height = rect.height * (window.devicePixelRatio || 1);
|
||||
sstvScopeCtx = canvas.getContext('2d');
|
||||
sstvScopeHistory = new Array(SSTV_SCOPE_LEN).fill(0);
|
||||
sstvScopeRms = 0;
|
||||
sstvScopePeak = 0;
|
||||
sstvScopeTargetRms = 0;
|
||||
sstvScopeTargetPeak = 0;
|
||||
sstvScopeMsgBurst = 0;
|
||||
sstvScopeTone = null;
|
||||
drawSstvScope();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw signal scope animation frame
|
||||
*/
|
||||
function drawSstvScope() {
|
||||
const ctx = sstvScopeCtx;
|
||||
if (!ctx) return;
|
||||
const W = ctx.canvas.width;
|
||||
const H = ctx.canvas.height;
|
||||
const midY = H / 2;
|
||||
|
||||
// Phosphor persistence
|
||||
ctx.fillStyle = 'rgba(5, 5, 16, 0.3)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Smooth towards target
|
||||
sstvScopeRms += (sstvScopeTargetRms - sstvScopeRms) * 0.25;
|
||||
sstvScopePeak += (sstvScopeTargetPeak - sstvScopePeak) * 0.15;
|
||||
|
||||
// Push to history
|
||||
sstvScopeHistory.push(Math.min(sstvScopeRms / 32768, 1.0));
|
||||
if (sstvScopeHistory.length > SSTV_SCOPE_LEN) sstvScopeHistory.shift();
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(60, 40, 80, 0.4)';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = (H / 4) * i;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||
}
|
||||
for (let i = 1; i < 8; i++) {
|
||||
const x = (W / 8) * i;
|
||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
|
||||
}
|
||||
|
||||
// Waveform
|
||||
const stepX = W / (SSTV_SCOPE_LEN - 1);
|
||||
ctx.strokeStyle = '#c080ff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.shadowColor = '#c080ff';
|
||||
ctx.shadowBlur = 4;
|
||||
|
||||
// Upper half
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||
const y = midY - amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Lower half (mirror)
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < sstvScopeHistory.length; i++) {
|
||||
const x = i * stepX;
|
||||
const amp = sstvScopeHistory[i] * midY * 0.9;
|
||||
const y = midY + amp;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Peak indicator
|
||||
const peakNorm = Math.min(sstvScopePeak / 32768, 1.0);
|
||||
if (peakNorm > 0.01) {
|
||||
const peakY = midY - peakNorm * midY * 0.9;
|
||||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(0, peakY); ctx.lineTo(W, peakY); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// Image decode flash
|
||||
if (sstvScopeMsgBurst > 0.01) {
|
||||
ctx.fillStyle = `rgba(0, 255, 100, ${sstvScopeMsgBurst * 0.15})`;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
sstvScopeMsgBurst *= 0.88;
|
||||
}
|
||||
|
||||
// Update labels
|
||||
const rmsLabel = document.getElementById('sstvScopeRmsLabel');
|
||||
const peakLabel = document.getElementById('sstvScopePeakLabel');
|
||||
const toneLabel = document.getElementById('sstvScopeToneLabel');
|
||||
const statusLabel = document.getElementById('sstvScopeStatusLabel');
|
||||
if (rmsLabel) rmsLabel.textContent = Math.round(sstvScopeRms);
|
||||
if (peakLabel) peakLabel.textContent = Math.round(sstvScopePeak);
|
||||
if (toneLabel) {
|
||||
if (sstvScopeTone === 'leader') { toneLabel.textContent = 'LEADER'; toneLabel.style.color = '#0f0'; }
|
||||
else if (sstvScopeTone === 'sync') { toneLabel.textContent = 'SYNC'; toneLabel.style.color = '#0ff'; }
|
||||
else if (sstvScopeTone === 'decoding') { toneLabel.textContent = 'DECODING'; toneLabel.style.color = '#fa0'; }
|
||||
else if (sstvScopeTone === 'noise') { toneLabel.textContent = 'NOISE'; toneLabel.style.color = '#555'; }
|
||||
else { toneLabel.textContent = 'QUIET'; toneLabel.style.color = '#444'; }
|
||||
}
|
||||
if (statusLabel) {
|
||||
if (sstvScopeRms > 500) { statusLabel.textContent = 'SIGNAL'; statusLabel.style.color = '#0f0'; }
|
||||
else { statusLabel.textContent = 'MONITORING'; statusLabel.style.color = '#555'; }
|
||||
}
|
||||
|
||||
sstvScopeAnim = requestAnimationFrame(drawSstvScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop signal scope
|
||||
*/
|
||||
function stopSstvScope() {
|
||||
if (sstvScopeAnim) { cancelAnimationFrame(sstvScopeAnim); sstvScopeAnim = null; }
|
||||
sstvScopeCtx = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSE stream
|
||||
*/
|
||||
@@ -642,6 +784,11 @@ const SSTV = (function() {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Show and init scope
|
||||
const scopePanel = document.getElementById('sstvScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'block';
|
||||
initSstvScope();
|
||||
|
||||
eventSource = new EventSource('/sstv/stream');
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
@@ -649,6 +796,10 @@ const SSTV = (function() {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'sstv_progress') {
|
||||
handleProgress(data);
|
||||
} else if (data.type === 'sstv_scope') {
|
||||
sstvScopeTargetRms = data.rms;
|
||||
sstvScopeTargetPeak = data.peak;
|
||||
if (data.tone !== undefined) sstvScopeTone = data.tone;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message:', err);
|
||||
@@ -671,6 +822,9 @@ const SSTV = (function() {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
stopSstvScope();
|
||||
const scopePanel = document.getElementById('sstvScopePanel');
|
||||
if (scopePanel) scopePanel.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -691,6 +845,7 @@ const SSTV = (function() {
|
||||
renderGallery();
|
||||
showNotification('SSTV', 'New image decoded!');
|
||||
updateStatusUI('listening', 'Listening...');
|
||||
sstvScopeMsgBurst = 1.0;
|
||||
// Clear decode progress so signal monitor can take over
|
||||
const liveContent = document.getElementById('sstvLiveContent');
|
||||
if (liveContent) liveContent.innerHTML = '';
|
||||
|
||||
Reference in New Issue
Block a user