diff --git a/web/api-client.js b/web/api-client.js
new file mode 100644
index 0000000..172a76e
--- /dev/null
+++ b/web/api-client.js
@@ -0,0 +1,74 @@
+(function (global) {
+ const DEFAULT_TIMEOUT_MS = 5000;
+
+ function toErrorMessage(err) {
+ if (!err) return 'request failed';
+ if (typeof err === 'string') return err;
+ return err.message || 'request failed';
+ }
+
+ function createClient(opts = {}) {
+ const baseUrl = opts.baseUrl || '';
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
+
+ async function request(path, options = {}) {
+ const controller = new AbortController();
+ const start = performance.now();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ const init = {
+ method: options.method || 'GET',
+ headers: { ...(options.headers || {}) },
+ signal: controller.signal,
+ };
+ if (options.body !== undefined) {
+ init.headers['Content-Type'] = 'application/json';
+ init.body = JSON.stringify(options.body);
+ }
+
+ try {
+ const res = await fetch(`${baseUrl}${path}`, init);
+ const durationMs = Math.round(performance.now() - start);
+ const contentType = res.headers.get('content-type') || '';
+ let data = null;
+ if (contentType.includes('application/json')) data = await res.json();
+ else data = await res.text();
+ if (!res.ok) {
+ const error = typeof data === 'string' ? data : (data?.error || `http ${res.status}`);
+ return { ok: false, status: res.status, error, data, meta: { duration_ms: durationMs } };
+ }
+ return { ok: true, status: res.status, data, meta: { duration_ms: durationMs } };
+ } catch (err) {
+ const durationMs = Math.round(performance.now() - start);
+ return { ok: false, status: 0, error: toErrorMessage(err), data: null, meta: { duration_ms: durationMs } };
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
+ return {
+ getConfig: () => request('/api/config'),
+ postConfig: (payload) => request('/api/config', { method: 'POST', body: payload }),
+ postSettings: (payload) => request('/api/sdr/settings', { method: 'POST', body: payload }),
+ getSignals: () => request('/api/signals'),
+ getStats: () => request('/api/stats'),
+ getGPU: () => request('/api/gpu'),
+ getPolicy: () => request('/api/pipeline/policy'),
+ getRecommendations: () => request('/api/pipeline/recommendations'),
+ getRefinement: () => request('/api/refinement'),
+ getEvents: ({ limit, since } = {}) => {
+ const params = new URLSearchParams();
+ if (Number.isFinite(limit) && limit > 0) params.set('limit', String(limit));
+ if (Number.isFinite(since) && since > 0) params.set('since', String(since));
+ const suffix = params.toString() ? `?${params.toString()}` : '';
+ return request(`/api/events${suffix}`);
+ },
+ getRecordings: () => request('/api/recordings'),
+ getRecording: (id) => request(`/api/recordings/${encodeURIComponent(id)}`),
+ decodeRecording: (id, mode) => request(`/api/recordings/${encodeURIComponent(id)}/decode?mode=${encodeURIComponent(mode || '')}`),
+ getDecoders: () => request('/api/decoders'),
+ getTelemetryLive: () => request('/api/debug/telemetry/live'),
+ };
+ }
+
+ global.SpectreApi = { createClient };
+})(window);
diff --git a/web/app.js b/web/app.js
index 80866b0..1d15071 100644
--- a/web/app.js
+++ b/web/app.js
@@ -69,14 +69,17 @@ const refineMaxSpan = qs('refineMaxSpan');
const resMaxRefine = qs('resMaxRefine');
const resMaxRecord = qs('resMaxRecord');
const resMaxDecode = qs('resMaxDecode');
+const resDecisionHold = qs('resDecisionHold');
const signalList = qs('signalList');
const signalDecisionSummary = qs('signalDecisionSummary');
+const signalQueueSummary = qs('signalQueueSummary');
const eventList = qs('eventList');
const recordingList = qs('recordingList');
const signalCountBadge = qs('signalCountBadge');
const eventCountBadge = qs('eventCountBadge');
const recordingCountBadge = qs('recordingCountBadge');
+const signalSummaryLine = qs('signalSummaryLine');
const healthBuffer = qs('healthBuffer');
const healthDropped = qs('healthDropped');
@@ -86,6 +89,16 @@ const healthGpu = qs('healthGpu');
const healthFps = qs('healthFps');
const healthRefinePlan = qs('healthRefinePlan');
const healthRefineWindows = qs('healthRefineWindows');
+const healthWs = qs('healthWs');
+const healthApi = qs('healthApi');
+const healthConfig = qs('healthConfig');
+const healthRefine = qs('healthRefine');
+const healthTelemetry = qs('healthTelemetry');
+const healthSource = qs('healthSource');
+const refineDetails = qs('refineDetails');
+const telemetryEventList = qs('telemetryEventList');
+const policySummaryList = qs('policySummaryList');
+const policyRecommendationList = qs('policyRecommendationList');
const drawerEl = qs('eventDrawer');
const drawerCloseBtn = qs('drawerClose');
@@ -134,11 +147,35 @@ let latest = null;
let currentConfig = null;
let liveAudio = null;
let liveListenWS = null; // WebSocket-based live listen
+let spectrumWS = null;
let liveListenTarget = null; // { freq, bw, mode }
let liveListenInfo = null;
let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 };
let refinementInfo = {};
let decisionIndex = new Map();
+let telemetryLive = null;
+let policyInfo = null;
+let policyRecommendations = null;
+let wsState = 'init';
+let wsLastMessageTs = 0;
+let wsCarriesSignals = false;
+let wsCarriesDebug = false;
+let apiState = { ok: false, latencyMs: null, lastOkTs: 0, lastError: '' };
+const apiClient = window.SpectreApi?.createClient
+ ? window.SpectreApi.createClient({ timeoutMs: 4500 })
+ : null;
+const operatorPanel = window.OperatorPanel?.create
+ ? window.OperatorPanel.create({
+ healthWs,
+ healthApi,
+ healthConfig,
+ healthRefine,
+ healthTelemetry,
+ healthSource,
+ refineDetails,
+ telemetryEvents: telemetryEventList
+ })
+ : null;
// ---------------------------------------------------------------------------
// LiveListenWS — WebSocket-based gapless audio streaming via /ws/audio
@@ -258,12 +295,25 @@ class LiveListenWS {
}
_onPCM(buf) {
+ const chunk = new Int16Array(buf);
+ const maxPendingFrames = Math.ceil(this.sampleRate * 0.25);
+ const maxPendingSamples = maxPendingFrames * Math.max(1, this.channels);
+
+ if (this._pendingLen + chunk.length > maxPendingSamples) {
+ this._pendingSamples = [chunk];
+ this._pendingLen = chunk.length;
+ if (this._flushTimer) {
+ clearTimeout(this._flushTimer);
+ this._flushTimer = 0;
+ }
+ } else {
+ this._pendingSamples.push(chunk);
+ this._pendingLen += chunk.length;
+ }
+
// Coalesce small chunks: accumulate until we have >= 40ms or 50ms passes.
// This reduces BufferSource scheduling overhead from ~12/sec to ~6/sec
// and produces larger, more stable buffers.
- this._pendingSamples.push(new Int16Array(buf));
- this._pendingLen += buf.byteLength / 2; // Int16 = 2 bytes
-
const minFrames = Math.ceil(this.sampleRate * 0.04); // 40ms worth
const haveFrames = Math.floor(this._pendingLen / this.channels);
@@ -552,9 +602,26 @@ let dragStartPan = 0;
let navDrag = false;
let timelineFrozen = false;
+// Keep the browser path best-effort under load so audio work wins over paint churn.
+const TARGET_VISUAL_FPS = 24;
+const VISUAL_FRAME_INTERVAL_MS = 1000 / TARGET_VISUAL_FPS;
+const WATERFALL_FRAME_INTERVAL_MS = 1000 / 10;
+const LIST_RENDER_INTERVAL_MS = 250;
+const HERO_RENDER_INTERVAL_MS = 200;
+const STATUS_RENDER_INTERVAL_MS = 250;
+
let renderFrames = 0;
let renderFps = 0;
let lastFpsTs = performance.now();
+let lastVisualRenderTs = 0;
+let lastWaterfallRenderTs = 0;
+let lastListRenderTs = 0;
+let lastHeroRenderTs = 0;
+let lastStatusRenderTs = 0;
+let pendingWaterfallRender = true;
+let pendingListRender = true;
+let pendingHeroRender = true;
+let pendingStatusRender = true;
let wsReconnectTimer = null;
let eventsFetchInFlight = false;
@@ -575,9 +642,11 @@ const timelineWindowMs = 5 * 60 * 1000;
function setConfigStatus(text) {
configStatusEl.textContent = text;
+ updateOperatorStatus();
}
function setWsBadge(text, kind = 'neutral') {
+ wsState = kind === 'ok' ? 'live' : (kind === 'bad' ? 'retrying' : 'connecting');
wsBadge.textContent = text;
wsBadge.style.borderColor = kind === 'ok'
? 'rgba(124, 251, 131, 0.35)'
@@ -586,6 +655,81 @@ function setWsBadge(text, kind = 'neutral') {
: 'rgba(112, 150, 207, 0.18)';
}
+function updateApiState(result) {
+ if (!result) return;
+ const latency = result.meta?.duration_ms;
+ if (Number.isFinite(latency)) apiState.latencyMs = latency;
+ if (result.ok) {
+ apiState.ok = true;
+ apiState.lastOkTs = Date.now();
+ apiState.lastError = '';
+ } else {
+ apiState.ok = false;
+ apiState.lastError = result.error || (result.status ? `HTTP ${result.status}` : 'request failed');
+ }
+}
+
+function fmtAgeShort(ms) {
+ if (!Number.isFinite(ms) || ms < 0) return '-';
+ if (ms < 1000) return `${Math.round(ms)} ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`;
+ return `${Math.round(ms / 60000)} min`;
+}
+
+function renderOperatorStatusNow() {
+ if (operatorPanel) {
+ operatorPanel.updateStatus({
+ wsState,
+ wsLastMessageTs,
+ apiState,
+ configStatusText: configStatusEl?.textContent || '-',
+ refinementInfo,
+ telemetryLive,
+ sourceAgeMs: stats?.last_sample_ago_ms
+ });
+ return;
+ }
+ if (healthWs) {
+ const age = wsLastMessageTs > 0 ? fmtAgeShort(Date.now() - wsLastMessageTs) : '-';
+ healthWs.textContent = `${wsState} | last ${age}`;
+ }
+ if (healthApi) {
+ const latency = Number.isFinite(apiState.latencyMs) ? `${apiState.latencyMs} ms` : 'n/a';
+ healthApi.textContent = apiState.ok ? `ok | ${latency}` : `degraded | ${apiState.lastError || 'n/a'}`;
+ }
+ if (healthConfig) {
+ healthConfig.textContent = configStatusEl?.textContent || '-';
+ }
+ if (healthRefine) {
+ const plan = refinementInfo.plan || {};
+ const queue = refinementInfo.arbitration?.queue || {};
+ healthRefine.textContent = `${plan.selected?.length || 0}/${plan.budget || 0} | q ${queue.record_queued || 0}/${queue.decode_queued || 0}`;
+ }
+ if (healthTelemetry) {
+ if (!telemetryLive) {
+ healthTelemetry.textContent = 'unavailable';
+ } else {
+ const enabled = telemetryLive.enabled === false ? 'off' : 'on';
+ const collector = telemetryLive.collector || {};
+ const recent = Array.isArray(telemetryLive.recent_events) ? telemetryLive.recent_events.length : 0;
+ const heavy = collector.heavy_enabled ? 'heavy' : 'light';
+ healthTelemetry.textContent = `${enabled} | ${heavy} | events ${recent}`;
+ }
+ }
+}
+
+function flushOperatorStatus(now, force = false) {
+ if (!pendingStatusRender) return;
+ if (!force && now - lastStatusRenderTs < STATUS_RENDER_INTERVAL_MS) return;
+ pendingStatusRender = false;
+ lastStatusRenderTs = now;
+ renderOperatorStatusNow();
+}
+
+function updateOperatorStatus(force = false) {
+ pendingStatusRender = true;
+ if (force) flushOperatorStatus(performance.now(), true);
+}
function toMHz(hz) { return hz / 1e6; }
function fromMHz(mhz) { return mhz * 1e6; }
function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; }
@@ -642,6 +786,7 @@ function renderSignalPopover(rect, signal) {
signalPopover.innerHTML = `
${primaryMode}${signal.class?.pll?.rds_station ? ' · ' + signal.class.pll.rds_station : ''}
${fmtMHz(signal.class?.pll?.exact_hz || signal.center_hz, 5)} · ${fmtKHz(signal.bw_hz || 0)} · ${(signal.snr_db || 0).toFixed(1)} dB SNR${runtimeInfo ? ` · ${runtimeInfo}` : ''}${signal.class?.pll?.locked ? ` · PLL ${signal.class.pll.method} LOCK` : ''}${signal.class?.pll?.stereo ? ' · STEREO' : ''}
${rows || '
No classifier scores
'}
`;
const popW = 220;
const top = rect.y + 8;
+ const left = rect.x + (rect.w / 2) - (popW / 2);
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`;
@@ -738,6 +883,7 @@ function drawThresholdOverlay(ctx, w, h, minDb, maxDb) {
function markSpectrumDirty() {
processingDirty = true;
+ pendingWaterfallRender = true;
}
function processSpectrum(spectrum) {
@@ -797,6 +943,7 @@ function resizeCanvas(canvas) {
function resizeAll() {
[navCanvas, spectrumCanvas, waterfallCanvas, occupancyCanvas, timelineCanvas, detailSpectrogram].forEach(resizeCanvas);
+ pendingWaterfallRender = true;
}
window.addEventListener('resize', resizeAll);
resizeAll();
@@ -883,78 +1030,143 @@ function applyConfigToUI(cfg) {
}
async function loadConfig() {
- try {
- const res = await fetch('/api/config');
- if (!res.ok) throw new Error('config');
- currentConfig = await res.json();
+ if (!apiClient) return;
+ const res = await apiClient.getConfig();
+ updateApiState(res);
+ if (res.ok && res.data) {
+ currentConfig = res.data;
applyConfigToUI(currentConfig);
setConfigStatus('Config synced');
- } catch {
+ } else {
setConfigStatus('Config offline');
}
+ updateOperatorStatus();
}
async function loadSignals() {
- try {
- const res = await fetch('/api/signals');
- if (!res.ok) return;
- const sigs = await res.json();
- if (Array.isArray(sigs)) {
- latest = latest || {};
- latest.signals = sigs;
- renderLists();
- }
- } catch {}
+ if (!apiClient) return;
+ const res = await apiClient.getSignals();
+ updateApiState(res);
+ if (!res.ok || !Array.isArray(res.data)) return;
+ latest = latest || {};
+ latest.signals = res.data;
+ updateHeroMetrics();
+ renderLists();
}
async function loadDecoders() {
- if (!decodeModeSelect) return;
- try {
- const res = await fetch('/api/decoders');
- if (!res.ok) return;
- const list = await res.json();
- if (!Array.isArray(list)) return;
- const current = decodeModeSelect.value;
- decodeModeSelect.innerHTML = '';
- list.forEach((mode) => {
- const opt = document.createElement('option');
- opt.value = mode;
- opt.textContent = mode;
- decodeModeSelect.appendChild(opt);
- });
- if (current) decodeModeSelect.value = current;
- } catch {}
+ if (!decodeModeSelect || !apiClient) return;
+ const res = await apiClient.getDecoders();
+ updateApiState(res);
+ if (!res.ok || !Array.isArray(res.data)) return;
+ const current = decodeModeSelect.value;
+ decodeModeSelect.innerHTML = '';
+ res.data.forEach((mode) => {
+ const opt = document.createElement('option');
+ opt.value = mode;
+ opt.textContent = mode;
+ decodeModeSelect.appendChild(opt);
+ });
+ if (current) decodeModeSelect.value = current;
}
async function loadStats() {
- try {
- const res = await fetch('/api/stats');
- if (!res.ok) return;
- stats = await res.json();
- } catch {}
+ if (!apiClient) return;
+ const res = await apiClient.getStats();
+ updateApiState(res);
+ if (res.ok && res.data) stats = res.data;
+ updateHeroMetrics();
+ updateOperatorStatus();
}
async function loadGPU() {
- try {
- const res = await fetch('/api/gpu');
- if (!res.ok) return;
- gpuInfo = await res.json();
- } catch {}
+ if (!apiClient) return;
+ const res = await apiClient.getGPU();
+ updateApiState(res);
+ if (res.ok && res.data) gpuInfo = res.data;
+ updateHeroMetrics();
+ updateOperatorStatus();
}
async function loadRefinement() {
- try {
- const res = await fetch('/api/refinement');
- if (!res.ok) return;
- refinementInfo = await res.json();
- decisionIndex = new Map();
- const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : [];
- items.forEach(item => {
- if (item && item.id != null) decisionIndex.set(String(item.id), item);
- });
- updateSignalDecisionSummary(window._selectedSignal?.id);
- updateSignalQueueSummary();
- } catch {}
+ if (!apiClient) return;
+ const res = await apiClient.getRefinement();
+ updateApiState(res);
+ if (!res.ok || !res.data) return;
+ refinementInfo = res.data;
+ decisionIndex = new Map();
+ const items = Array.isArray(refinementInfo.arbitration?.decision_items) ? refinementInfo.arbitration.decision_items : [];
+ items.forEach(item => {
+ if (item && item.id != null) decisionIndex.set(String(item.id), item);
+ });
+ updateSignalDecisionSummary(window._selectedSignal?.id);
+ updateSignalQueueSummary();
+ updateHeroMetrics();
+ updateOperatorStatus();
+}
+
+async function loadTelemetryLive() {
+ if (!apiClient) return;
+ const res = await apiClient.getTelemetryLive();
+ updateApiState(res);
+ if (!res.ok) {
+ telemetryLive = null;
+ updateOperatorStatus();
+ return;
+ }
+ telemetryLive = res.data;
+ updateOperatorStatus();
+}
+
+function renderKvList(root, rows, emptyText) {
+ if (!root) return;
+ if (!rows || !rows.length) {
+ root.innerHTML = `${emptyText}
`;
+ return;
+ }
+ root.innerHTML = rows.map(({ key, value }) => ``).join('');
+}
+
+function renderPolicyLists() {
+ if (policySummaryList) {
+ if (!policyInfo) {
+ renderKvList(policySummaryList, [], 'Policy unavailable.');
+ } else {
+ renderKvList(policySummaryList, [
+ { key: 'Profile', value: policyInfo.profile || 'n/a' },
+ { key: 'Mode', value: policyInfo.mode || 'n/a' },
+ { key: 'Intent', value: policyInfo.intent || 'n/a' },
+ { key: 'Surveillance', value: policyInfo.surveillance_strategy || 'n/a' },
+ { key: 'Refinement', value: policyInfo.refinement_strategy || 'n/a' }
+ ], 'Policy unavailable.');
+ }
+ }
+ if (policyRecommendationList) {
+ if (!policyRecommendations) {
+ renderKvList(policyRecommendationList, [], 'Recommendations unavailable.');
+ } else {
+ renderKvList(policyRecommendationList, [
+ { key: 'Monitor span', value: Number.isFinite(policyRecommendations.monitor_span_hz) ? fmtHz(policyRecommendations.monitor_span_hz) : 'n/a' },
+ { key: 'Refine jobs', value: policyRecommendations.refinement_jobs ?? 'n/a' },
+ { key: 'Detail FFT', value: policyRecommendations.refinement_detail_fft ?? 'n/a' },
+ { key: 'Auto span', value: policyRecommendations.refinement_auto_span ?? 'n/a' },
+ { key: 'Auto record', value: Array.isArray(policyRecommendations.auto_record_classes) && policyRecommendations.auto_record_classes.length ? policyRecommendations.auto_record_classes.join(', ') : 'n/a' },
+ { key: 'Auto decode', value: Array.isArray(policyRecommendations.auto_decode_classes) && policyRecommendations.auto_decode_classes.length ? policyRecommendations.auto_decode_classes.join(', ') : 'n/a' }
+ ], 'Recommendations unavailable.');
+ }
+ }
+}
+
+async function loadPolicy() {
+ if (!apiClient) return;
+ const [policyRes, recRes] = await Promise.all([
+ apiClient.getPolicy(),
+ apiClient.getRecommendations()
+ ]);
+ updateApiState(policyRes.ok ? policyRes : recRes);
+ policyInfo = policyRes.ok ? policyRes.data : null;
+ policyRecommendations = recRes.ok ? recRes.data : null;
+ renderPolicyLists();
}
function formatLevelSummary(level) {
@@ -989,17 +1201,14 @@ async function sendConfigUpdate() {
if (!pendingConfigUpdate) return;
const payload = pendingConfigUpdate;
pendingConfigUpdate = null;
- try {
- const res = await fetch('/api/config', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- });
- if (!res.ok) throw new Error('apply');
- currentConfig = await res.json();
+ if (!apiClient) return;
+ const res = await apiClient.postConfig(payload);
+ updateApiState(res);
+ if (res.ok && res.data) {
+ currentConfig = res.data;
applyConfigToUI(currentConfig);
setConfigStatus('Config applied');
- } catch {
+ } else {
setConfigStatus('Config apply failed');
}
}
@@ -1008,22 +1217,19 @@ async function sendSettingsUpdate() {
if (!pendingSettingsUpdate) return;
const payload = pendingSettingsUpdate;
pendingSettingsUpdate = null;
- try {
- const res = await fetch('/api/sdr/settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- });
- if (!res.ok) throw new Error('apply');
- currentConfig = await res.json();
+ if (!apiClient) return;
+ const res = await apiClient.postSettings(payload);
+ updateApiState(res);
+ if (res.ok && res.data) {
+ currentConfig = res.data;
applyConfigToUI(currentConfig);
setConfigStatus('Settings applied');
- } catch {
+ } else {
setConfigStatus('Settings apply failed');
}
}
-function updateHeroMetrics() {
+function renderHeroMetricsNow() {
if (!latest) return;
const span = latest.sample_rate / zoom;
const binHz = latest.sample_rate / Math.max(1, latest.spectrum_db?.length || latest.fft_size || 1);
@@ -1095,6 +1301,19 @@ function updateHeroMetrics() {
}
}
+function flushHeroMetrics(now, force = false) {
+ if (!pendingHeroRender) return;
+ if (!force && now - lastHeroRenderTs < HERO_RENDER_INTERVAL_MS) return;
+ pendingHeroRender = false;
+ lastHeroRenderTs = now;
+ renderHeroMetricsNow();
+}
+
+function updateHeroMetrics(force = false) {
+ pendingHeroRender = true;
+ if (force) flushHeroMetrics(performance.now(), true);
+}
+
function renderBandNavigator() {
if (!latest) return;
const ctx = navCanvas.getContext('2d');
@@ -1570,6 +1789,35 @@ function updateSignalDecisionSummary(id) {
signalDecisionSummary.textContent = `Decision: ${flags}${reason}`;
}
+function updateSignalQueueSummary() {
+ if (!signalQueueSummary) return;
+ const queue = refinementInfo?.arbitration?.queue;
+ if (!queue) {
+ signalQueueSummary.textContent = 'Queue: -';
+ return;
+ }
+ signalQueueSummary.textContent = `Queue: rec ${queue.record_queued || 0} / dec ${queue.decode_queued || 0}`;
+}
+
+function setSelectedSignal(sel) {
+ window._selectedSignal = sel || null;
+ signalList.querySelectorAll('.signal-item').forEach((el) => {
+ const active = !!sel && ((sel.key && el.dataset.key === sel.key) || (!sel.key && sel.id && el.dataset.id === String(sel.id)));
+ el.classList.toggle('active', active);
+ });
+ updateSignalDecisionSummary(window._selectedSignal?.id);
+ updateSignalQueueSummary();
+}
+
+function getSignalDomKey(s) {
+ if (s?.id != null && s.id !== '') return `id:${s.id}`;
+ const center = Math.round(Number(s?.center_hz || 0));
+ const bw = Math.round(Number(s?.bw_hz || 0));
+ const mode = getSignalPrimaryMode(s) || s?.class?.mod_type || 'UNK';
+ const rds = s?.class?.pll?.rds_station || '';
+ return `sig:${center}:${bw}:${mode}:${rds}`;
+}
+
function _createSignalItem(s) {
const btn = document.createElement('button');
btn.className = 'list-item signal-item';
@@ -1577,7 +1825,8 @@ function _createSignalItem(s) {
btn.dataset.center = s.center_hz;
btn.dataset.bw = s.bw_hz || 0;
btn.dataset.class = s.class?.mod_type || '';
- btn.dataset.id = s.id || 0;
+ btn.dataset.id = s.id ?? '';
+ btn.dataset.key = getSignalDomKey(s);
const primaryMode = getSignalPrimaryMode(s);
const runtimeInfo = getSignalRuntimeSummary(s);
const mc = modColor(primaryMode);
@@ -1632,51 +1881,64 @@ function _patchSignalItem(el, s) {
el.dataset.center = s.center_hz;
el.dataset.bw = s.bw_hz || 0;
el.dataset.class = mod;
+ el.dataset.id = s.id ?? '';
+ el.dataset.key = getSignalDomKey(s);
el.style.borderLeftColor = mc.label;
el.classList.toggle('listening', matchesListenTarget(s));
}
-function renderLists() {
+function renderListsNow() {
const signals = Array.isArray(latest?.signals) ? [...latest.signals] : [];
signals.sort((a, b) => (b.snr_db || 0) - (a.snr_db || 0));
signalCountBadge.textContent = `${signals.length} live`;
metricSignals.textContent = String(signals.length);
const displaySigs = signals.slice(0, 24);
- const wantIds = new Set(displaySigs.map(s => String(s.id || 0)));
-
- // Remove empty-state placeholder if signals exist
- const emptyEl = signalList.querySelector('.empty-state');
- if (emptyEl && displaySigs.length > 0) emptyEl.remove();
-
- // Remove DOM items whose signal ID is no longer present
- signalList.querySelectorAll('.signal-item').forEach(el => {
- if (!wantIds.has(el.dataset.id)) el.remove();
- });
+ const strongest = displaySigs[0] || null;
+ const selectedSignal = window._selectedSignal || null;
+ if (signalSummaryLine) {
+ const strongestText = strongest
+ ? `${fmtMHz(strongest.center_hz, 4)} · ${(strongest.snr_db || 0).toFixed(1)} dB`
+ : '-';
+ const selectedText = selectedSignal && Number.isFinite(selectedSignal.freq)
+ ? `${fmtMHz(selectedSignal.freq, 4)}${selectedSignal.mode ? ` · ${selectedSignal.mode}` : ''}`
+ : '-';
+ signalSummaryLine.textContent = `Visible: ${displaySigs.length} · Strongest: ${strongestText} · Selected: ${selectedText}`;
+ }
if (displaySigs.length === 0) {
- if (!signalList.querySelector('.empty-state')) {
- signalList.innerHTML = 'No live signals yet.
';
- }
+ signalList.innerHTML = 'No live signals yet.
';
} else {
- // Build map of existing DOM items
- const domById = new Map();
- signalList.querySelectorAll('.signal-item').forEach(el => domById.set(el.dataset.id, el));
-
- displaySigs.forEach(s => {
- const id = String(s.id || 0);
- const existing = domById.get(id);
- if (existing) {
- _patchSignalItem(existing, s);
+ const existing = new Map();
+ signalList.querySelectorAll('.signal-item').forEach((el) => existing.set(el.dataset.key, el));
+ const frag = document.createDocumentFragment();
+
+ displaySigs.forEach((s) => {
+ const key = getSignalDomKey(s);
+ let el = existing.get(key);
+ if (el) {
+ _patchSignalItem(el, s);
} else {
- const el = _createSignalItem(s);
- // Auto-select if it matches the user's last selection
- if (window._selectedSignal && Math.abs(s.center_hz - window._selectedSignal.freq) < 50000) {
- el.classList.add('active');
- }
- signalList.appendChild(el);
+ el = _createSignalItem(s);
+ }
+
+ if (window._selectedSignal) {
+ const selectedKey = window._selectedSignal.key;
+ const selectedId = window._selectedSignal.id;
+ const sameKey = selectedKey && key === selectedKey;
+ const sameId = !selectedKey && selectedId && s.id != null && String(s.id) === String(selectedId);
+ const nearFreq = !selectedKey && !selectedId && Number.isFinite(window._selectedSignal.freq) && Math.abs(s.center_hz - window._selectedSignal.freq) < 2500;
+ el.classList.toggle('active', !!(sameKey || sameId || nearFreq));
+ } else {
+ el.classList.remove('active');
}
+
+ frag.appendChild(el);
+ existing.delete(key);
});
+
+ signalList.innerHTML = '';
+ signalList.appendChild(frag);
}
const recent = [...events].sort((a, b) => b.end_ms - a.end_ms);
@@ -1726,6 +1988,19 @@ function renderLists() {
updateSignalDecisionSummary(window._selectedSignal?.id);
}
+function flushLists(now, force = false) {
+ if (!pendingListRender) return;
+ if (!force && now - lastListRenderTs < LIST_RENDER_INTERVAL_MS) return;
+ pendingListRender = false;
+ lastListRenderTs = now;
+ renderListsNow();
+}
+
+function renderLists(force = false) {
+ pendingListRender = true;
+ if (force) flushLists(performance.now(), true);
+}
+
function normalizeEvent(ev) {
const startMs = new Date(ev.start).getTime();
const endMs = new Date(ev.end).getTime();
@@ -1756,33 +2031,33 @@ function upsertEvents(list, replace = false) {
events.splice(0, drop);
}
if (events.length > 0) lastEventEndMs = events[events.length - 1].end_ms;
+ updateHeroMetrics();
renderLists();
}
async function fetchEvents(initial) {
- if (eventsFetchInFlight || timelineFrozen) return;
+ if (eventsFetchInFlight || timelineFrozen || !apiClient) return;
eventsFetchInFlight = true;
try {
- let url = '/api/events?limit=1000';
- if (!initial && lastEventEndMs > 0) url = `/api/events?since=${lastEventEndMs - 1}`;
- const res = await fetch(url);
- if (!res.ok) return;
- const data = await res.json();
- if (Array.isArray(data)) upsertEvents(data, initial);
+ const query = initial
+ ? { limit: 1000 }
+ : (lastEventEndMs > 0 ? { since: lastEventEndMs - 1 } : { limit: 200 });
+ const res = await apiClient.getEvents(query);
+ updateApiState(res);
+ if (res.ok && Array.isArray(res.data)) upsertEvents(res.data, initial);
} finally {
eventsFetchInFlight = false;
}
}
async function fetchRecordings() {
- if (recordingsFetchInFlight || !recordingList) return;
+ if (recordingsFetchInFlight || !recordingList || !apiClient) return;
recordingsFetchInFlight = true;
try {
- const res = await fetch('/api/recordings');
- if (!res.ok) return;
- const data = await res.json();
- if (Array.isArray(data)) {
- recordings = data;
+ const res = await apiClient.getRecordings();
+ updateApiState(res);
+ if (res.ok && Array.isArray(res.data)) {
+ recordings = res.data;
renderLists();
}
} finally {
@@ -1838,14 +2113,14 @@ function openDrawer(ev) {
drawerEl.classList.add('open');
drawerEl.setAttribute('aria-hidden', 'false');
renderDetailSpectrogram();
- renderLists();
+ renderLists(true);
}
function closeDrawer() {
drawerEl.classList.remove('open');
drawerEl.setAttribute('aria-hidden', 'true');
selectedEventId = null;
- renderLists();
+ renderLists(true);
}
function fitView() {
@@ -1861,6 +2136,29 @@ function tuneToFrequency(centerHz) {
queueConfigUpdate({ center_hz: centerHz });
}
+function applyLiveFrame(frame) {
+ if (!frame) return;
+ const next = frame;
+ if (!wsCarriesSignals && Array.isArray(latest?.signals)) {
+ next.signals = latest.signals;
+ }
+ if (!wsCarriesDebug) {
+ next.debug = null;
+ }
+ latest = next;
+ pendingWaterfallRender = true;
+ updateHeroMetrics();
+}
+
+function sendSpectrumWSConfig(update) {
+ if (!spectrumWS || spectrumWS.readyState !== WebSocket.OPEN) return;
+ try {
+ spectrumWS.send(JSON.stringify(update));
+ } catch (err) {
+ console.warn('ws config update failed:', err);
+ }
+}
+
function connect() {
clearTimeout(wsReconnectTimer);
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
@@ -1877,40 +2175,54 @@ function connect() {
const wantBinary = params.get('binary') === '1' || !isLocal;
const bins = parseInt(params.get('bins') || (isLocal ? '0' : '2048'), 10);
const fps = parseInt(params.get('fps') || (isLocal ? '0' : '10'), 10);
+ const wantSignals = params.get('signals') === '1';
+ const wantDebug = params.get('debug') === '1' || showDebugOverlay;
+ wsCarriesSignals = wantSignals;
+ wsCarriesDebug = wantDebug;
let wsUrl = `${proto}://${location.host}/ws`;
- if (wantBinary || bins > 0 || fps > 0) {
+ if (wantBinary || bins > 0 || fps > 0 || wantDebug || !wantSignals) {
const qp = [];
if (wantBinary) qp.push('binary=1');
if (bins > 0) qp.push(`bins=${bins}`);
if (fps > 0) qp.push(`fps=${fps}`);
+ if (wantDebug) qp.push('debug=1');
+ if (!wantSignals) qp.push('signals=0');
wsUrl += '?' + qp.join('&');
}
const ws = new WebSocket(wsUrl);
+ spectrumWS = ws;
ws.binaryType = 'arraybuffer';
setWsBadge('Connecting', 'neutral');
- ws.onopen = () => setWsBadge('Live', 'ok');
+ ws.onopen = () => {
+ setWsBadge('Live', 'ok');
+ wsLastMessageTs = Date.now();
+ updateOperatorStatus(true);
+ };
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
try {
const decoded = decodeBinaryFrame(ev.data);
- if (decoded) latest = decoded;
+ applyLiveFrame(decoded);
} catch (e) {
console.warn('binary frame decode error:', e);
return;
}
} else {
- latest = JSON.parse(ev.data);
+ applyLiveFrame(JSON.parse(ev.data));
}
+ wsLastMessageTs = Date.now();
+ updateOperatorStatus();
markSpectrumDirty();
if (followLive) pan = 0;
- updateHeroMetrics();
renderLists();
};
ws.onclose = () => {
+ if (spectrumWS === ws) spectrumWS = null;
setWsBadge('Retrying', 'bad');
+ updateOperatorStatus(true);
wsReconnectTimer = setTimeout(connect, 1000);
};
ws.onerror = () => ws.close();
@@ -1969,24 +2281,32 @@ function decodeBinaryFrame(buf) {
};
}
-function renderLoop() {
- renderFrames += 1;
- const now = performance.now();
- if (now - lastFpsTs >= 1000) {
- renderFps = (renderFrames * 1000) / (now - lastFpsTs);
- renderFrames = 0;
- lastFpsTs = now;
- }
+function renderLoop(now) {
+ flushOperatorStatus(now);
+ flushHeroMetrics(now);
+ flushLists(now);
+
+ if (latest && (lastVisualRenderTs === 0 || now - lastVisualRenderTs >= VISUAL_FRAME_INTERVAL_MS)) {
+ lastVisualRenderTs = now;
+ renderFrames += 1;
+ if (now - lastFpsTs >= 1000) {
+ renderFps = (renderFrames * 1000) / (now - lastFpsTs);
+ renderFrames = 0;
+ lastFpsTs = now;
+ updateHeroMetrics();
+ }
- if (latest) {
renderBandNavigator();
renderSpectrum();
- renderWaterfall();
+ if (pendingWaterfallRender && (lastWaterfallRenderTs === 0 || now - lastWaterfallRenderTs >= WATERFALL_FRAME_INTERVAL_MS)) {
+ renderWaterfall();
+ pendingWaterfallRender = false;
+ lastWaterfallRenderTs = now;
+ }
renderOccupancy();
renderTimeline();
if (drawerEl.classList.contains('open')) renderDetailSpectrogram();
}
- updateHeroMetrics();
requestAnimationFrame(renderLoop);
}
@@ -2003,11 +2323,12 @@ function handleSpectrumClick(ev) {
const bw = sig.bw_hz || 12000;
const mode = sig.class?.mod_type || '';
startLiveListen(freq, bw, mode);
- window._selectedSignal = { freq, bw, mode };
- // Update selected signal in list
- signalList.querySelectorAll('.signal-item').forEach(el => {
- const elFreq = parseFloat(el.dataset.center || '0');
- el.classList.toggle('active', Math.abs(elFreq - freq) < Math.max(500, bw * 0.5));
+ setSelectedSignal({
+ key: getSignalDomKey(sig),
+ id: sig.id ?? null,
+ freq,
+ bw,
+ mode
});
return;
}
@@ -2276,9 +2597,12 @@ maxHoldToggle.addEventListener('change', () => {
});
if (debugOverlayToggle) debugOverlayToggle.addEventListener('change', () => {
showDebugOverlay = debugOverlayToggle.checked;
+ wsCarriesDebug = showDebugOverlay;
localStorage.setItem('spectre.debugOverlay', showDebugOverlay ? '1' : '0');
+ if (!showDebugOverlay && latest) latest.debug = null;
+ sendSpectrumWSConfig({ debug: showDebugOverlay });
markSpectrumDirty();
- updateHeroMetrics();
+ updateHeroMetrics(true);
});
resetMaxBtn.addEventListener('click', () => {
maxSpectrum = null;
@@ -2299,13 +2623,26 @@ presetButtons.forEach((btn) => {
});
});
-railTabs.forEach((tab) => {
- tab.addEventListener('click', () => {
- railTabs.forEach(t => t.classList.toggle('active', t === tab));
- tabPanels.forEach(panel => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab));
+function activateRailTab(tabName) {
+ railTabs.forEach((t) => {
+ const active = t.dataset.tab === tabName;
+ t.classList.toggle('active', active);
+ t.setAttribute('aria-selected', active ? 'true' : 'false');
+ });
+ tabPanels.forEach((panel) => {
+ const active = panel.dataset.panel === tabName;
+ panel.classList.toggle('active', active);
+ panel.hidden = !active;
+ panel.setAttribute('aria-hidden', active ? 'false' : 'true');
});
+}
+
+railTabs.forEach((tab) => {
+ tab.addEventListener('click', () => activateRailTab(tab.dataset.tab));
});
+activateRailTab((railTabs.find((t) => t.classList.contains('active')) || railTabs[0])?.dataset.tab || 'radio');
+
drawerCloseBtn.addEventListener('click', closeDrawer);
exportEventBtn.addEventListener('click', exportSelectedEvent);
@@ -2337,13 +2674,17 @@ if (decodeEventBtn) {
return;
}
const mode = decodeModeSelect?.value || ev.class?.mod_type || 'FT8';
- const res = await fetch(`/api/recordings/${rec.id}/decode?mode=${mode}`);
- if (!res.ok) {
+ if (!apiClient) {
+ decodeResultEl.textContent = 'Decode: failed';
+ return;
+ }
+ const res = await apiClient.decodeRecording(rec.id, mode);
+ updateApiState(res);
+ if (!res.ok || !res.data) {
decodeResultEl.textContent = 'Decode: failed';
return;
}
- const data = await res.json();
- decodeResultEl.textContent = `Decode: ${String(data.stdout || '').slice(0, 80)}`;
+ decodeResultEl.textContent = `Decode: ${String(res.data.stdout || '').slice(0, 80)}`;
});
}
jumpToEventBtn.addEventListener('click', () => {
@@ -2369,18 +2710,13 @@ signalList.addEventListener('click', (ev) => {
const target = ev.target.closest('.signal-item');
if (!target) return;
// Select this signal for live listening — don't retune the SDR
- const allItems = signalList.querySelectorAll('.signal-item');
- allItems.forEach(el => el.classList.remove('active'));
- target.classList.add('active');
- // Store selected signal data for Live Listen button
- window._selectedSignal = {
+ setSelectedSignal({
+ key: target.dataset.key || null,
id: target.dataset.id || null,
freq: parseFloat(target.dataset.center),
bw: parseFloat(target.dataset.bw || '12000'),
mode: target.dataset.class || ''
- };
- updateSignalDecisionSummary(window._selectedSignal.id);
- updateSignalQueueSummary();
+ });
});
if (liveListenBtn) {
@@ -2431,9 +2767,11 @@ if (recordingList) {
recordingAudioLink.href = `/api/recordings/${id}/audio`;
}
try {
- const res = await fetch(`/api/recordings/${id}`);
- if (!res.ok) return;
- const meta = await res.json();
+ if (!apiClient) return;
+ const res = await apiClient.getRecording(id);
+ updateApiState(res);
+ if (!res.ok || !res.data) return;
+ const meta = res.data;
if (decodeResultEl) {
const rds = meta.rds_ps ? `RDS: ${meta.rds_ps}` : '';
decodeResultEl.textContent = `Decode: ${rds}`;
@@ -2486,11 +2824,14 @@ window.addEventListener('keydown', (ev) => {
}
});
+updateOperatorStatus(true);
loadConfig();
resetLiveListenMeta();
loadStats();
loadGPU();
loadRefinement();
+loadTelemetryLive();
+loadPolicy();
fetchEvents(true);
fetchRecordings();
loadDecoders();
@@ -2499,12 +2840,9 @@ requestAnimationFrame(renderLoop);
setInterval(loadStats, 1000);
setInterval(loadGPU, 1000);
setInterval(loadRefinement, 1500);
+setInterval(loadTelemetryLive, 3000);
+setInterval(loadPolicy, 10000);
setInterval(() => fetchEvents(false), 2000);
setInterval(fetchRecordings, 5000);
setInterval(loadSignals, 1500);
setInterval(loadDecoders, 10000);
-
-
-
-
-
diff --git a/web/index.html b/web/index.html
index 6cc4546..adf642a 100644
--- a/web/index.html
+++ b/web/index.html
@@ -103,6 +103,10 @@
+
+
+
+
@@ -174,8 +178,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Detected carriers0 live
+ Visible: 0 · Strongest: - · Selected: -
Decision: -
Queue: -
@@ -316,6 +355,25 @@
GPU status-
Render rate-
+
+
Operator Status
+
+
WS-
+
API-
+
Config-
+
Source-
+
Refinement-
+
Telemetry-
+
+
+
+
Refinement Snapshot
+
Loading refinement snapshot...
+
+
+
Telemetry Recent Events
+
Loading telemetry events...
+
@@ -391,6 +449,7 @@
Recording: -
Decode: -
Classifier scores: -
+
+
+