const qs = (id) => document.getElementById(id); const navCanvas = qs('navCanvas'); const spectrumCanvas = qs('spectrum'); const waterfallCanvas = qs('waterfall'); const occupancyCanvas = qs('occupancy'); const timelineCanvas = qs('timeline'); const detailSpectrogram = qs('detailSpectrogram'); const signalPopover = qs('signalPopover'); const wsBadge = qs('wsBadge'); const metaLine = qs('metaLine'); const heroSubtitle = qs('heroSubtitle'); const configStatusEl = qs('configStatus'); const timelineRangeEl = qs('timelineRange'); const metricCenter = qs('metricCenter'); const metricSpan = qs('metricSpan'); const metricRes = qs('metricRes'); const metricSignals = qs('metricSignals'); const metricGpu = qs('metricGpu'); const metricSource = qs('metricSource'); const centerInput = qs('centerInput'); const spanInput = qs('spanInput'); const sampleRateSelect = qs('sampleRateSelect'); const bwSelect = qs('bwSelect'); const fftSelect = qs('fftSelect'); const gainRange = qs('gainRange'); const gainInput = qs('gainInput'); const thresholdRange = qs('thresholdRange'); const thresholdInput = qs('thresholdInput'); const classifierModeSelect = qs('classifierModeSelect'); const cfarModeSelect = qs('cfarModeSelect'); const cfarWrapToggle = qs('cfarWrapToggle'); const cfarGuardHzInput = qs('cfarGuardHzInput'); const cfarTrainHzInput = qs('cfarTrainHzInput'); const cfarRankInput = qs('cfarRankInput'); const cfarScaleInput = qs('cfarScaleInput'); const minDurationInput = qs('minDurationInput'); const holdInput = qs('holdInput'); const emaAlphaInput = qs('emaAlphaInput'); const hysteresisInput = qs('hysteresisInput'); const stableFramesInput = qs('stableFramesInput'); const gapToleranceInput = qs('gapToleranceInput'); const edgeMarginInput = qs('edgeMarginInput'); const mergeGapInput = qs('mergeGapInput'); const classHistoryInput = qs('classHistoryInput'); const classSwitchInput = qs('classSwitchInput'); const agcToggle = qs('agcToggle'); const dcToggle = qs('dcToggle'); const iqToggle = qs('iqToggle'); const avgSelect = qs('avgSelect'); const maxHoldToggle = qs('maxHoldToggle'); const gpuToggle = qs('gpuToggle'); const recEnableToggle = qs('recEnableToggle'); const recIQToggle = qs('recIQToggle'); const recAudioToggle = qs('recAudioToggle'); const recDemodToggle = qs('recDemodToggle'); const recDecodeToggle = qs('recDecodeToggle'); const recMinSNR = qs('recMinSNR'); const recMaxDisk = qs('recMaxDisk'); const recClassFilter = qs('recClassFilter'); const signalList = qs('signalList'); const eventList = qs('eventList'); const recordingList = qs('recordingList'); const signalCountBadge = qs('signalCountBadge'); const eventCountBadge = qs('eventCountBadge'); const recordingCountBadge = qs('recordingCountBadge'); const healthBuffer = qs('healthBuffer'); const healthDropped = qs('healthDropped'); const healthResets = qs('healthResets'); const healthAge = qs('healthAge'); const healthGpu = qs('healthGpu'); const healthFps = qs('healthFps'); const drawerEl = qs('eventDrawer'); const drawerCloseBtn = qs('drawerClose'); const detailSubtitle = qs('detailSubtitle'); const detailCenterEl = qs('detailCenter'); const detailBwEl = qs('detailBw'); const detailStartEl = qs('detailStart'); const detailEndEl = qs('detailEnd'); const detailSnrEl = qs('detailSnr'); const detailDurEl = qs('detailDur'); const detailClassEl = qs('detailClass'); const jumpToEventBtn = qs('jumpToEventBtn'); const exportEventBtn = qs('exportEventBtn'); const liveListenEventBtn = qs('liveListenEventBtn'); const decodeEventBtn = qs('decodeEventBtn'); const decodeModeSelect = qs('decodeMode'); const recordingMetaEl = qs('recordingMeta'); const decodeResultEl = qs('decodeResult'); const classifierScoresEl = qs('classifierScores'); const classifierScoreBarsEl = qs('classifierScoreBars'); const recordingMetaLink = qs('recordingMetaLink'); const recordingIQLink = qs('recordingIQLink'); const recordingAudioLink = qs('recordingAudioLink'); const followBtn = qs('followBtn'); const fitBtn = qs('fitBtn'); const resetMaxBtn = qs('resetMaxBtn'); const debugOverlayToggle = qs('debugOverlayToggle'); const timelineFollowBtn = qs('timelineFollowBtn'); const timelineFreezeBtn = qs('timelineFreezeBtn'); const modeButtons = Array.from(document.querySelectorAll('.mode-btn')); const railTabs = Array.from(document.querySelectorAll('.rail-tab')); const tabPanels = Array.from(document.querySelectorAll('.tab-panel')); const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); const liveListenBtn = qs('liveListenBtn'); const listenSecondsInput = qs('listenSeconds'); const listenModeSelect = qs('listenMode'); let latest = null; let currentConfig = null; let liveAudio = null; let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; let gpuInfo = { available: false, active: false, error: '' }; let zoom = 1; let pan = 0; let followLive = true; let maxHold = false; let avgAlpha = 0; let avgSpectrum = null; let maxSpectrum = null; let lastFFTSize = null; let processedSpectrum = null; let processedSpectrumSource = null; let processingDirty = true; let pendingConfigUpdate = null; let pendingSettingsUpdate = null; let configTimer = null; let settingsTimer = null; let isSyncingConfig = false; let isDraggingSpectrum = false; let dragStartX = 0; let dragStartPan = 0; let navDrag = false; let timelineFrozen = false; let renderFrames = 0; let renderFps = 0; let lastFpsTs = performance.now(); let wsReconnectTimer = null; let eventsFetchInFlight = false; const events = []; const eventsById = new Map(); let lastEventEndMs = 0; let selectedEventId = null; let timelineRects = []; let liveSignalRects = []; let recordings = []; let recordingsFetchInFlight = false; let showDebugOverlay = localStorage.getItem('spectre.debugOverlay') !== '0'; let hoveredSignal = null; let popoverHideTimer = null; const GAIN_MAX = 60; const timelineWindowMs = 5 * 60 * 1000; function setConfigStatus(text) { configStatusEl.textContent = text; } function setWsBadge(text, kind = 'neutral') { wsBadge.textContent = text; wsBadge.style.borderColor = kind === 'ok' ? 'rgba(124, 251, 131, 0.35)' : kind === 'bad' ? 'rgba(255, 107, 129, 0.35)' : 'rgba(112, 150, 207, 0.18)'; } function toMHz(hz) { return hz / 1e6; } function fromMHz(mhz) { return mhz * 1e6; } function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; } function fmtKHz(hz, digits = 2) { return `${(hz / 1e3).toFixed(digits)} kHz`; } function fmtHz(hz) { if (hz >= 1e6) return `${(hz / 1e6).toFixed(3)} MHz`; if (hz >= 1e3) return `${(hz / 1e3).toFixed(2)} kHz`; return `${hz.toFixed(0)} Hz`; } function fmtMs(ms) { if (ms < 1000) return `${Math.max(0, Math.round(ms))} ms`; return `${(ms / 1000).toFixed(2)} s`; } function scoreEntries(scores, limit = 6) { if (!scores || typeof scores !== 'object') return []; return Object.entries(scores) .filter(([, v]) => Number.isFinite(Number(v))) .sort((a, b) => Number(b[1]) - Number(a[1])) .slice(0, limit); } function renderScoreBars(scores) { if (!classifierScoreBarsEl) return; const entries = scoreEntries(scores); if (!entries.length) { classifierScoreBarsEl.innerHTML = ''; return; } const maxVal = Math.max(...entries.map(([, v]) => Number(v)), 1e-6); classifierScoreBarsEl.innerHTML = entries.map(([label, value]) => { const width = Math.max(4, (Number(value) / maxVal) * 100); return `
${label}${Number(value).toFixed(2)}
`; }).join(''); } function hideSignalPopover() { hoveredSignal = null; if (!signalPopover) return; signalPopover.classList.remove('open'); signalPopover.setAttribute('aria-hidden', 'true'); } function renderSignalPopover(rect, signal) { if (!signalPopover || !signal) return; const entries = scoreEntries(signal.class?.scores || signal.debug_scores || {}, 4); const maxVal = Math.max(...entries.map(([, v]) => Number(v)), 1e-6); const rows = entries.map(([label, value]) => { const width = Math.max(4, (Number(value) / maxVal) * 100); return `
${label}${Number(value).toFixed(2)}
`; }).join(''); const pllMeta = signal.class?.pll?.locked ? ` • PLL ${signal.class.pll.method} LOCK ±${signal.class.pll.precision_hz} Hz` : ''; signalPopover.innerHTML = `
${signal.class?.mod_type || 'Signal'}
${fmtMHz(signal.class?.pll?.exact_hz || signal.center_hz, 5)} • ${fmtKHz(signal.bw_hz || 0)} • ${(signal.snr_db || 0).toFixed(1)} dB SNR${pllMeta}
${rows || '
No classifier scores
'}
`; const popW = 220; const left = rect.x + rect.w + 8; const top = rect.y + 8; const maxLeft = Math.max(8, spectrumCanvas.width - popW - 8); signalPopover.style.left = `${Math.max(8, Math.min(maxLeft, left))}px`; signalPopover.style.top = `${Math.max(8, top)}px`; signalPopover.classList.add('open'); signalPopover.setAttribute('aria-hidden', 'false'); } function colorMap(v) { const x = Math.max(0, Math.min(1, v)); const r = Math.floor(255 * Math.pow(x, 0.55)); const g = Math.floor(255 * Math.pow(x, 1.08)); const b = Math.floor(220 * Math.pow(1 - x, 1.15)); return [r, g, b]; } function snrColor(snr) { const norm = Math.max(0, Math.min(1, (snr + 5) / 35)); const [r, g, b] = colorMap(norm); return `rgb(${r}, ${g}, ${b})`; } function binForFreq(freq, centerHz, sampleRate, n) { return Math.floor((freq - (centerHz - sampleRate / 2)) / (sampleRate / n)); } function maxInBinRange(spectrum, b0, b1) { const n = spectrum.length; let start = Math.max(0, Math.min(n - 1, b0)); let end = Math.max(0, Math.min(n - 1, b1)); if (end < start) [start, end] = [end, start]; let max = -1e9; for (let i = start; i <= end; i++) { if (spectrum[i] > max) max = spectrum[i]; } return max; } function sampleOverlayAtX(overlay, x, width, centerHz, sampleRate) { if (!Array.isArray(overlay) || overlay.length === 0 || width <= 0) return null; const n = overlay.length; const span = sampleRate / zoom; const startHz = centerHz - span / 2 + pan * span; const endHz = centerHz + span / 2 + pan * span; const f1 = startHz + (x / width) * (endHz - startHz); const f2 = startHz + ((x + 1) / width) * (endHz - startHz); const b0 = binForFreq(f1, centerHz, sampleRate, n); const b1 = binForFreq(f2, centerHz, sampleRate, n); return maxInBinRange(overlay, b0, b1); } function drawThresholdOverlay(ctx, w, h, minDb, maxDb) { if (!showDebugOverlay) return; const thresholds = latest?.debug?.thresholds; if (!Array.isArray(thresholds) || thresholds.length === 0) return; ctx.save(); ctx.strokeStyle = 'rgba(255, 196, 92, 0.9)'; ctx.lineWidth = 1.25; if (ctx.setLineDash) ctx.setLineDash([6, 4]); ctx.beginPath(); for (let x = 0; x < w; x++) { const v = sampleOverlayAtX(thresholds, x, w, latest.center_hz, latest.sample_rate); if (v == null || Number.isNaN(v)) continue; const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 18) - 6; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); if (ctx.setLineDash) ctx.setLineDash([]); ctx.fillStyle = 'rgba(255, 196, 92, 0.95)'; ctx.font = '11px Inter, sans-serif'; ctx.fillText('CFAR', 8, 14); ctx.restore(); } function markSpectrumDirty() { processingDirty = true; } function processSpectrum(spectrum) { if (!spectrum) return spectrum; let base = spectrum; if (avgAlpha > 0) { if (!avgSpectrum || avgSpectrum.length !== spectrum.length) { avgSpectrum = spectrum.slice(); } else { for (let i = 0; i < spectrum.length; i++) { avgSpectrum[i] = avgAlpha * spectrum[i] + (1 - avgAlpha) * avgSpectrum[i]; } } base = avgSpectrum; } if (maxHold) { if (!maxSpectrum || maxSpectrum.length !== base.length) { maxSpectrum = base.slice(); } else { for (let i = 0; i < base.length; i++) { if (base[i] > maxSpectrum[i]) maxSpectrum[i] = base[i]; } } base = maxSpectrum; } return base; } function resetProcessingCaches() { avgSpectrum = null; maxSpectrum = null; processedSpectrum = null; processedSpectrumSource = null; processingDirty = true; } function getProcessedSpectrum() { if (!latest?.spectrum_db) return null; if (!processingDirty && processedSpectrumSource === latest.spectrum_db) return processedSpectrum; processedSpectrum = processSpectrum(latest.spectrum_db); processedSpectrumSource = latest.spectrum_db; processingDirty = false; return processedSpectrum; } function resizeCanvas(canvas) { if (!canvas) return; const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const width = Math.max(1, Math.floor(rect.width * dpr)); const height = Math.max(1, Math.floor(rect.height * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } } function resizeAll() { [navCanvas, spectrumCanvas, waterfallCanvas, occupancyCanvas, timelineCanvas, detailSpectrogram].forEach(resizeCanvas); } window.addEventListener('resize', resizeAll); resizeAll(); function setSelectValueOrNearest(selectEl, numericValue) { if (!selectEl) return; const options = Array.from(selectEl.options || []); const exact = options.find(o => Number.parseFloat(o.value) === numericValue); if (exact) { selectEl.value = exact.value; return; } let best = options[0]; let bestDist = Infinity; for (const opt of options) { const dist = Math.abs(Number.parseFloat(opt.value) - numericValue); if (dist < bestDist) { best = opt; bestDist = dist; } } if (best) selectEl.value = best.value; } function applyConfigToUI(cfg) { if (!cfg) return; isSyncingConfig = true; centerInput.value = toMHz(cfg.center_hz).toFixed(6); setSelectValueOrNearest(sampleRateSelect, cfg.sample_rate / 1e6); setSelectValueOrNearest(bwSelect, cfg.tuner_bw_khz || 1536); setSelectValueOrNearest(fftSelect, cfg.fft_size); if (lastFFTSize !== cfg.fft_size) { resetProcessingCaches(); lastFFTSize = cfg.fft_size; } const uiGain = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - cfg.gain_db)); gainRange.value = uiGain; gainInput.value = uiGain; thresholdRange.value = cfg.detector.threshold_db; thresholdInput.value = cfg.detector.threshold_db; if (classifierModeSelect) classifierModeSelect.value = cfg.classifier_mode || 'combined'; if (cfarModeSelect) cfarModeSelect.value = cfg.detector.cfar_mode || 'OFF'; if (cfarWrapToggle) cfarWrapToggle.checked = cfg.detector.cfar_wrap_around !== false; if (cfarGuardHzInput) cfarGuardHzInput.value = cfg.detector.cfar_guard_hz ?? 500; if (cfarTrainHzInput) cfarTrainHzInput.value = cfg.detector.cfar_train_hz ?? 5000; if (cfarRankInput) cfarRankInput.value = cfg.detector.cfar_rank ?? 24; if (cfarScaleInput) cfarScaleInput.value = cfg.detector.cfar_scale_db ?? 6; const rankRow = cfarRankInput?.closest('.field'); if (rankRow) rankRow.style.display = (cfg.detector.cfar_mode === 'OS') ? '' : 'none'; if (minDurationInput) minDurationInput.value = cfg.detector.min_duration_ms; if (holdInput) holdInput.value = cfg.detector.hold_ms; if (emaAlphaInput) emaAlphaInput.value = cfg.detector.ema_alpha ?? 0.2; if (hysteresisInput) hysteresisInput.value = cfg.detector.hysteresis_db ?? 3; if (stableFramesInput) stableFramesInput.value = cfg.detector.min_stable_frames ?? 3; if (gapToleranceInput) gapToleranceInput.value = cfg.detector.gap_tolerance_ms ?? cfg.detector.hold_ms; if (edgeMarginInput) edgeMarginInput.value = cfg.detector.edge_margin_db ?? 3.0; if (mergeGapInput) mergeGapInput.value = cfg.detector.merge_gap_hz ?? 5000; if (classHistoryInput) classHistoryInput.value = cfg.detector.class_history_size ?? 10; if (classSwitchInput) classSwitchInput.value = cfg.detector.class_switch_ratio ?? 0.6; agcToggle.checked = !!cfg.agc; dcToggle.checked = !!cfg.dc_block; iqToggle.checked = !!cfg.iq_balance; gpuToggle.checked = !!cfg.use_gpu_fft; maxHoldToggle.checked = maxHold; if (cfg.recorder) { if (recEnableToggle) recEnableToggle.checked = !!cfg.recorder.enabled; if (recIQToggle) recIQToggle.checked = !!cfg.recorder.record_iq; if (recAudioToggle) recAudioToggle.checked = !!cfg.recorder.record_audio; if (recDemodToggle) recDemodToggle.checked = !!cfg.recorder.auto_demod; if (recDecodeToggle) recDecodeToggle.checked = !!cfg.recorder.auto_decode; if (recMinSNR) recMinSNR.value = cfg.recorder.min_snr_db ?? 10; if (recMaxDisk) recMaxDisk.value = cfg.recorder.max_disk_mb ?? 0; if (recClassFilter) recClassFilter.value = (cfg.recorder.class_filter || []).join(', '); } spanInput.value = (cfg.sample_rate / zoom / 1e6).toFixed(3); isSyncingConfig = false; } async function loadConfig() { try { const res = await fetch('/api/config'); if (!res.ok) throw new Error('config'); currentConfig = await res.json(); applyConfigToUI(currentConfig); setConfigStatus('Config synced'); } catch { setConfigStatus('Config offline'); } } thresholdInput.addEventListener('change', () => { const v = parseFloat(thresholdInput.value); if (Number.isFinite(v)) { thresholdRange.value = v; queueConfigUpdate({ detector: { threshold_db: v } }); } }); if (classifierModeSelect) classifierModeSelect.addEventListener('change', () => { queueConfigUpdate({ classifier_mode: classifierModeSelect.value }); }); if (cfarGuardHzInput) cfarGuardHzInput.addEventListener('change', () => { const v = parseFloat(cfarGuardHzInput.value); if (Number.isFinite(v) && v >= 0) queueConfigUpdate({ detector: { cfar_guard_hz: v } }); }); if (cfarTrainHzInput) cfarTrainHzInput.addEventListener('change', () => { const v = parseFloat(cfarTrainHzInput.value); if (Number.isFinite(v) && v > 0) queueConfigUpdate({ detector: { cfar_train_hz: v } }); }); if (mergeGapInput) mergeGapInput.addEventListener('change', () => { const v = parseFloat(mergeGapInput.value); if (Number.isFinite(v)) queueConfigUpdate({ detector: { merge_gap_hz: v } }); }); if (classHistoryInput) classHistoryInput.addEventListener('change', () => { const v = parseInt(classHistoryInput.value, 10); if (Number.isFinite(v) && v >= 1) queueConfigUpdate({ detector: { class_history_size: v } }); }); if (classSwitchInput) classSwitchInput.addEventListener('change', () => { const v = parseFloat(classSwitchInput.value); if (Number.isFinite(v) && v >= 0.1 && v <= 1.0) queueConfigUpdate({ detector: { class_switch_ratio: v } }); });