diff --git a/web/app.js b/web/app.js
index 9d94b27..16b2fe2 100644
--- a/web/app.js
+++ b/web/app.js
@@ -127,8 +127,8 @@ const listenModeSelect = qs('listenMode');
const listenMetaDemod = qs('listenMetaDemod');
const listenMetaPlayback = qs('listenMetaPlayback');
const listenMetaStereo = qs('listenMetaStereo');
-const listenMetaRate = qs('listenMetaRate');
-const listenMetaChannels = qs('listenMetaChannels');
+const listenMetaStatus = qs('listenMetaStatus');
+const listenMetaAudio = qs('listenMetaAudio');
let latest = null;
let currentConfig = null;
@@ -289,8 +289,9 @@ class LiveListenWS {
}
const liveListenDefaults = {
+ status: 'Idle',
demod: '-',
- playback_mode: 'Inactive',
+ playback_mode: '-',
stereo_state: '-',
sample_rate: null,
channels: null
@@ -301,23 +302,51 @@ function formatListenMetaValue(value, fallback = '-') {
return String(value);
}
+function isListeningSignal(signal) {
+ return !!(signal && liveListenTarget && matchesListenTarget(signal));
+}
+
+function getSignalPrimaryMode(signal) {
+ if (isListeningSignal(signal) && liveListenInfo?.playback_mode && liveListenInfo.playback_mode !== '-') {
+ return liveListenInfo.playback_mode;
+ }
+ if (signal?.class?.mod_type) return signal.class.mod_type;
+ return 'carrier';
+}
+
+function getSignalRuntimeSummary(signal) {
+ if (!isListeningSignal(signal)) return '';
+ const bits = [];
+ if (liveListenInfo?.status && !['Idle', '-'].includes(liveListenInfo.status)) bits.push(liveListenInfo.status);
+ if (liveListenInfo?.stereo_state && liveListenInfo.stereo_state !== '-') bits.push(liveListenInfo.stereo_state);
+ return bits.join(' · ');
+}
+
+function getSignalAudioSummary(signal) {
+ if (!isListeningSignal(signal)) return '';
+ const rate = Number.isFinite(liveListenInfo?.sample_rate) && liveListenInfo.sample_rate > 0 ? fmtHz(liveListenInfo.sample_rate) : '';
+ const ch = Number.isFinite(liveListenInfo?.channels) && liveListenInfo.channels > 0 ? `${liveListenInfo.channels} ch` : '';
+ return [rate, ch].filter(Boolean).join(' · ');
+}
+
function renderLiveListenMeta(info) {
if (!listenMetaDemod) return;
+ const status = formatListenMetaValue(info?.status, 'Idle');
const demod = formatListenMetaValue(info?.demod);
- const playback = formatListenMetaValue(info?.playback_mode, 'Inactive');
+ const playback = formatListenMetaValue(info?.playback_mode);
const stereo = formatListenMetaValue(info?.stereo_state);
const sampleRate = Number.isFinite(info?.sample_rate) && info.sample_rate > 0
? fmtHz(info.sample_rate)
: '-';
const channels = Number.isFinite(info?.channels) && info.channels > 0
- ? String(info.channels)
+ ? `${info.channels} ch`
: '-';
- listenMetaDemod.textContent = demod;
+ if (listenMetaStatus) listenMetaStatus.textContent = status;
listenMetaPlayback.textContent = playback;
listenMetaStereo.textContent = stereo;
- listenMetaRate.textContent = sampleRate;
- listenMetaChannels.textContent = channels;
+ listenMetaDemod.textContent = `Demod ${demod}`;
+ if (listenMetaAudio) listenMetaAudio.textContent = `Audio ${sampleRate}${channels !== '-' ? ` · ${channels}` : ''}`;
}
function resetLiveListenMeta() {
@@ -332,7 +361,7 @@ function updateLiveListenMeta(partial) {
function handleLiveListenAudioInfo(info) {
if (!info || typeof info !== 'object') return;
- const partial = {};
+ const partial = { status: 'Live' };
if (info.demod) partial.demod = info.demod;
if (info.playback_mode) partial.playback_mode = info.playback_mode;
if (info.stereo_state) partial.stereo_state = info.stereo_state;
@@ -349,6 +378,15 @@ function resolveListenMode(detectedMode) {
return detectedMode || 'NFM';
}
+function syncListeningVisuals() {
+ signalList?.querySelectorAll('.signal-item').forEach(el => {
+ const center = parseFloat(el.dataset.center || '0');
+ const bw = parseFloat(el.dataset.bw || '0');
+ const fakeSignal = { center_hz: center, bw_hz: bw };
+ el.classList.toggle('listening', matchesListenTarget(fakeSignal));
+ });
+}
+
function setLiveListenUI(active) {
if (liveListenBtn) {
liveListenBtn.textContent = active ? '■ Stop' : 'Live Listen';
@@ -369,6 +407,7 @@ function stopLiveListen() {
liveListenTarget = null;
setLiveListenUI(false);
resetLiveListenMeta();
+ syncListeningVisuals();
}
function startLiveListen(freq, bw, detectedMode) {
@@ -397,10 +436,12 @@ function startLiveListen(freq, bw, detectedMode) {
});
liveListenWS.start();
setLiveListenUI(true);
+ syncListeningVisuals();
const startingInfo = {
+ status: 'Connecting',
demod: mode || '-',
- playback_mode: 'Starting',
+ playback_mode: mode || '-',
stereo_state: mode === 'WFM_STEREO' ? 'searching' : 'mono',
sample_rate: 48000,
channels: mode === 'WFM_STEREO' ? 2 : 1
@@ -526,8 +567,9 @@ function renderSignalPopover(rect, signal) {
const width = Math.max(4, (Number(value) / maxVal) * 100);
return `
${label}${Number(value).toFixed(2)}
`;
}).join('');
- signalPopover.innerHTML = `${signal.class?.mod_type || 'Signal'}${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${signal.class?.pll?.locked ? ` · PLL ${signal.class.pll.method} LOCK` : ''}${signal.class?.pll?.stereo ? ' · STEREO' : ''}
${rows || '
No classifier scores
'}
`;
- const popW = 220;
+ const primaryMode = getSignalPrimaryMode(signal);
+ const runtimeInfo = getSignalRuntimeSummary(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 left = rect.x + rect.w + 8;
const top = rect.y + 8;
const maxLeft = Math.max(8, spectrumCanvas.width - popW - 8);
@@ -1157,12 +1199,14 @@ function renderSpectrum() {
const x2 = ((right - startHz) / (endHz - startHz)) * w;
const boxW = Math.max(2, x2 - x1);
const mod = s.class?.mod_type || '';
- const mc = modColor(mod);
+ const primaryMode = getSignalPrimaryMode(s);
+ const mc = modColor(primaryMode);
const rdsName = s.class?.pll?.rds_station || '';
+ const runtimeInfo = getSignalRuntimeSummary(s);
// Signal box with modulation-based color
- ctx.fillStyle = modColorStr(mod, 0.10);
- ctx.strokeStyle = modColorStr(mod, 0.75);
+ ctx.fillStyle = modColorStr(primaryMode, 0.10);
+ ctx.strokeStyle = modColorStr(primaryMode, 0.75);
ctx.lineWidth = 1.5;
ctx.fillRect(x1, 10, boxW, h - 28);
ctx.strokeRect(x1, 10, boxW, h - 28);
@@ -1179,10 +1223,11 @@ function renderSpectrum() {
const freqStr = `${(s.center_hz / 1e6).toFixed(4)} MHz`;
// Badge background
- const badgeH = rdsName ? 42 : (mod ? 30 : 16);
+ const badgeH = rdsName ? 42 : ((primaryMode || runtimeInfo) ? 30 : 16);
const freqW = ctx.measureText ? 0 : 0; // will measure below
ctx.font = '11px Inter, sans-serif';
- const textW = Math.max(ctx.measureText(freqStr).width, mod ? ctx.measureText(mod).width : 0, rdsName ? ctx.measureText(rdsName).width : 0) + 8;
+ const line2 = runtimeInfo || primaryMode;
+ const textW = Math.max(ctx.measureText(freqStr).width, line2 ? ctx.measureText(line2).width : 0, rdsName ? ctx.measureText(rdsName).width : 0) + 8;
ctx.fillStyle = 'rgba(7, 16, 24, 0.82)';
ctx.fillRect(labelX - 3, baseY, textW, badgeH);
@@ -1191,11 +1236,11 @@ function renderSpectrum() {
ctx.font = '11px Inter, sans-serif';
ctx.fillText(freqStr, labelX, baseY + 11);
- // Line 2: Mod type (modulation color)
- if (mod) {
+ // Line 2: runtime status or primary mode
+ if (runtimeInfo || primaryMode) {
ctx.fillStyle = mc.label;
ctx.font = 'bold 10px Inter, sans-serif';
- ctx.fillText(mod, labelX, baseY + 23);
+ ctx.fillText(runtimeInfo || primaryMode, labelX, baseY + 23);
}
// Line 3: RDS station name (white bold)
@@ -1465,13 +1510,16 @@ function _createSignalItem(s) {
btn.dataset.class = s.class?.mod_type || '';
btn.dataset.id = s.id || 0;
const mod = s.class?.mod_type || '';
- const mc = modColor(mod);
+ const primaryMode = getSignalPrimaryMode(s);
+ const mc = modColor(primaryMode);
const rds = s.class?.pll?.rds_station || '';
+ const runtime = getSignalRuntimeSummary(s);
+ const audio = getSignalAudioSummary(s);
const dec = decisionIndex.get(String(s.id || 0));
const decText = dec?.reason ? `${dec.reason}` : '';
const decFlags = dec ? `${dec.record ? 'REC' : ''}${dec.decode ? (dec.record ? '+DEC' : 'DEC') : ''}` : '';
const decMeta = decText || decFlags ? `${decFlags}${decFlags && decText ? ' · ' : ''}${decText}` : '';
- btn.innerHTML = `${fmtMHz(s.center_hz, 6)}${(s.snr_db || 0).toFixed(1)} dB
${mod || 'carrier'}BW ${fmtKHz(s.bw_hz || 0)}${rds ? `${rds}` : ''}${decMeta}
`;
+ btn.innerHTML = `${fmtMHz(s.center_hz, 6)}${(s.snr_db || 0).toFixed(1)} dB
${primaryMode}${runtime ? `${runtime}` : ''}BW ${fmtKHz(s.bw_hz || 0)}${audio ? `${audio}` : ''}${rds ? `${rds}` : ''}${decMeta}
`;
btn.style.borderLeftColor = mc.label;
btn.style.borderLeftWidth = '3px';
btn.style.borderLeftStyle = 'solid';
@@ -1483,23 +1531,39 @@ function _patchSignalItem(el, s) {
const freqEl = el.querySelector('[data-field="freq"]');
const snrEl = el.querySelector('[data-field="snr"]');
const bwEl = el.querySelector('[data-field="bw"]');
- const modEl = el.querySelector('[data-field="mod"]');
+ const modeEl = el.querySelector('[data-field="mode"]');
+ const runtimeEl = el.querySelector('[data-field="runtime"]');
+ const audioEl = el.querySelector('[data-field="audio"]');
const rdsEl = el.querySelector('[data-field="rds"]');
const mod = s.class?.mod_type || '';
- const mc = modColor(mod);
+ const primaryMode = getSignalPrimaryMode(s);
+ const mc = modColor(primaryMode);
+ const runtime = getSignalRuntimeSummary(s);
+ const audio = getSignalAudioSummary(s);
const rds = s.class?.pll?.rds_station || '';
if (freqEl) freqEl.textContent = fmtMHz(s.center_hz, 6);
if (snrEl) { snrEl.textContent = `${(s.snr_db || 0).toFixed(1)} dB`; snrEl.style.color = snrColor(s.snr_db || 0); }
if (bwEl) bwEl.textContent = `BW ${fmtKHz(s.bw_hz || 0)}`;
- if (modEl) { modEl.textContent = mod || 'carrier'; modEl.style.color = mc.label; }
- if (rdsEl) { rdsEl.textContent = rds; } else if (rds && !rdsEl) {
+ if (modeEl) { modeEl.textContent = primaryMode; modeEl.style.color = mc.label; }
+ if (runtimeEl) {
+ runtimeEl.textContent = runtime;
+ runtimeEl.style.display = runtime ? '' : 'none';
+ }
+ if (audioEl) {
+ audioEl.textContent = audio;
+ audioEl.style.display = audio ? '' : 'none';
+ }
+ if (rdsEl) {
+ rdsEl.textContent = rds;
+ rdsEl.style.display = rds ? '' : 'none';
+ } else if (rds && !rdsEl) {
const span = document.createElement('span');
span.className = 'item-meta';
span.dataset.field = 'rds';
span.style.color = '#fff';
span.style.fontWeight = '700';
span.textContent = rds;
- el.querySelector('.item-bottom')?.appendChild(span);
+ el.querySelector('.signal-secondary')?.appendChild(span);
}
el.dataset.center = s.center_hz;
el.dataset.bw = s.bw_hz || 0;
@@ -2178,14 +2242,6 @@ railTabs.forEach((tab) => {
});
});
-modeButtons.forEach((btn) => {
- btn.addEventListener('click', () => {
- modeButtons.forEach(b => b.classList.toggle('active', b === btn));
- document.body.classList.remove('mode-live', 'mode-hunt', 'mode-review', 'mode-lab');
- document.body.classList.add(`mode-${btn.dataset.mode}`);
- });
-});
-document.body.classList.add('mode-live');
drawerCloseBtn.addEventListener('click', closeDrawer);
exportEventBtn.addEventListener('click', exportSelectedEvent);
@@ -2385,3 +2441,4 @@ setInterval(loadSignals, 1500);
setInterval(loadDecoders, 10000);
+
diff --git a/web/index.html b/web/index.html
index 969c85c..122200f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -26,14 +26,7 @@
-
+
Connecting
@@ -290,12 +283,18 @@
diff --git a/web/style.css b/web/style.css
index 1800183..c7d71a8 100644
--- a/web/style.css
+++ b/web/style.css
@@ -92,21 +92,8 @@ button, input, select { font: inherit; }
.topbar-center { display: flex; justify-content: center; }
.topbar-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
-/* Mode Strip */
-.mode-strip { display: flex; gap: 3px; background: var(--bg2); border: 1px solid var(--line); border-radius: 12px; padding: 3px; }
-.mode-btn {
- font-family: var(--mono); font-size: 0.72rem; font-weight: 500;
- padding: 5px 14px; border-radius: 9px;
- background: transparent; border: 1px solid transparent;
- color: var(--text-dim); cursor: pointer; transition: all 0.15s;
- display: flex; align-items: center; gap: 5px;
-}
-.mode-btn:hover { color: var(--text); background: rgba(255,255,255,0.03); }
-.mode-btn.active {
- background: rgba(0, 255, 200, 0.08); border-color: rgba(0, 255, 200, 0.2);
- color: var(--accent); box-shadow: 0 0 12px rgba(0, 255, 200, 0.06);
-}
-.mode-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); animation: pulse 2s ease-in-out infinite; }
+/* topbar center intentionally left available for future status content */
+.topbar-center { min-height: 1px; }
.ws-badge {
font-family: var(--mono); font-size: 0.68rem; font-weight: 500;
@@ -375,12 +362,18 @@ input[type="range"]::-moz-range-thumb {
.listen-meta {
margin-top: 8px;
- padding: 8px 10px;
+ padding: 10px;
border-radius: var(--r);
border: 1px solid var(--line);
- background: rgba(5, 8, 14, 0.6);
+ background: rgba(5, 8, 14, 0.72);
display: grid;
- gap: 4px;
+ gap: 6px;
+}
+.listen-meta__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
}
.listen-meta__title {
font-family: var(--mono);
@@ -389,20 +382,69 @@ input[type="range"]::-moz-range-thumb {
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-mute);
- margin-bottom: 2px;
}
-.listen-meta__row {
+.listen-meta__badge {
+ font-family: var(--mono);
+ font-size: 0.62rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: rgba(15, 25, 40, 0.8);
+ color: var(--text-dim);
+}
+.listen-meta__primary {
display: flex;
- align-items: center;
+ align-items: baseline;
justify-content: space-between;
- gap: 8px;
+ gap: 10px;
+}
+.listen-meta__mode {
+ font-family: var(--mono);
+ font-size: 0.9rem;
+ font-weight: 700;
+ color: var(--text);
+}
+.listen-meta__stereo {
+ font-family: var(--mono);
+ font-size: 0.68rem;
+ font-weight: 700;
+ color: var(--accent);
+ text-transform: uppercase;
+}
+.listen-meta__secondary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
font-family: var(--mono);
- font-size: 0.7rem;
+ font-size: 0.66rem;
color: var(--text-dim);
}
-.listen-meta__row span:last-child {
- color: var(--text);
- font-weight: 600;
+
+.signal-primary {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+.signal-secondary {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ flex-wrap: wrap;
+}
+.item-meta--runtime {
+ color: var(--accent);
+ font-weight: 700;
+}
+.item-meta--classifier {
+ opacity: 0.72;
+}
+.item-meta--live {
+ color: #fff;
+ font-weight: 700;
}
/* Health Grid */
@@ -492,12 +534,6 @@ input[type="range"]::-moz-range-thumb {
.kb-grid span { font-size: 0.82rem; color: var(--text-dim); }
.kb-close { width: 100%; justify-content: center; display: flex; }
-/* ═══════════ MODE VARIANTS ═══════════ */
-body.mode-hunt .tl-panel { grid-row: 2; }
-body.mode-review .stage { grid-template-rows: auto auto auto minmax(0, 0.76fr); }
-body.mode-review .tl-panel { box-shadow: 0 0 0 1px rgba(0, 144, 255, 0.12), var(--shadow); }
-body.mode-lab .hero-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
-
/* ═══════════ ANIMATIONS ═══════════ */
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }