diff --git a/static/css/modes/sstv-general.css b/static/css/modes/sstv-general.css index 9e2949d..9ebad84 100644 --- a/static/css/modes/sstv-general.css +++ b/static/css/modes/sstv-general.css @@ -389,6 +389,82 @@ margin-bottom: 12px; } +/* ============================================ + SIGNAL MONITOR + ============================================ */ +.sstv-general-signal-monitor { + width: 100%; + max-width: 320px; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.sstv-general-signal-monitor-header { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 14px; +} + +.sstv-general-signal-monitor-header svg { + color: var(--accent-cyan); +} + +.sstv-general-signal-level-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.sstv-general-signal-level-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.sstv-general-signal-bar-track { + flex: 1; + height: 6px; + background: var(--bg-primary); + border-radius: 3px; + overflow: hidden; +} + +.sstv-general-signal-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease, background 0.3s ease; + background: var(--text-dim); +} + +.sstv-general-signal-level-value { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + min-width: 24px; + text-align: right; +} + +.sstv-general-signal-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + /* ============================================ IMAGE MODAL ============================================ */ diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css index 6af1ac9..588a25b 100644 --- a/static/css/modes/sstv.css +++ b/static/css/modes/sstv.css @@ -736,6 +736,82 @@ animation: pulse 0.5s infinite; } +/* ============================================ + SIGNAL MONITOR + ============================================ */ +.sstv-signal-monitor { + width: 100%; + max-width: 320px; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.sstv-signal-monitor-header { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 14px; +} + +.sstv-signal-monitor-header svg { + color: var(--accent-cyan); +} + +.sstv-signal-level-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.sstv-signal-level-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.sstv-signal-bar-track { + flex: 1; + height: 6px; + background: var(--bg-primary); + border-radius: 3px; + overflow: hidden; +} + +.sstv-signal-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease, background 0.3s ease; + background: var(--text-dim); +} + +.sstv-signal-level-value { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + min-width: 24px; + text-align: right; +} + +.sstv-signal-status-text { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-dim); + text-align: center; +} + /* ============================================ IMAGE MODAL ============================================ */ diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index 020ee18..444ef0a 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -247,9 +247,71 @@ const SSTVGeneral = (function() { updateStatusUI('listening', 'Listening...'); } else if (data.status === 'detecting') { updateStatusUI('listening', data.message || 'Listening...'); + if (data.signal_level !== undefined) { + renderSignalMonitor(data); + } } } + /** + * Render signal monitor in live area during detecting mode + */ + function renderSignalMonitor(data) { + const container = document.getElementById('sstvGeneralLiveContent'); + if (!container) return; + + const level = data.signal_level || 0; + const tone = data.sstv_tone; + + let barColor, statusText; + if (tone === 'leader') { + barColor = 'var(--accent-green)'; + statusText = 'SSTV leader tone detected'; + } else if (tone === 'sync') { + barColor = 'var(--accent-cyan)'; + statusText = 'SSTV sync pulse detected'; + } else if (tone === 'noise') { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else if (level > 10) { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else { + barColor = 'var(--text-dim)'; + statusText = 'No signal'; + } + + let monitor = container.querySelector('.sstv-general-signal-monitor'); + if (!monitor) { + container.innerHTML = ` +
+
+ + + + + + Signal Monitor +
+
+ LEVEL +
+
+
+ 0 +
+
No signal
+
`; + monitor = container.querySelector('.sstv-general-signal-monitor'); + } + + const fill = monitor.querySelector('.sstv-general-signal-bar-fill'); + fill.style.width = level + '%'; + fill.style.background = barColor; + monitor.querySelector('.sstv-general-signal-status-text').textContent = statusText; + monitor.querySelector('.sstv-general-signal-level-value').textContent = level; + } + /** * Render decode progress in live area */ diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 89f60c3..2830f8a 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -682,9 +682,71 @@ const SSTV = (function() { updateStatusUI('listening', 'Listening...'); } else if (data.status === 'detecting') { updateStatusUI('listening', data.message || 'Listening...'); + if (data.signal_level !== undefined) { + renderSignalMonitor(data); + } } } + /** + * Render signal monitor in live area during detecting mode + */ + function renderSignalMonitor(data) { + const container = document.getElementById('sstvLiveContent'); + if (!container) return; + + const level = data.signal_level || 0; + const tone = data.sstv_tone; + + let barColor, statusText; + if (tone === 'leader') { + barColor = 'var(--accent-green)'; + statusText = 'SSTV leader tone detected'; + } else if (tone === 'sync') { + barColor = 'var(--accent-cyan)'; + statusText = 'SSTV sync pulse detected'; + } else if (tone === 'noise') { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else if (level > 10) { + barColor = 'var(--text-dim)'; + statusText = 'Audio signal present'; + } else { + barColor = 'var(--text-dim)'; + statusText = 'No signal'; + } + + let monitor = container.querySelector('.sstv-signal-monitor'); + if (!monitor) { + container.innerHTML = ` +
+
+ + + + + + Signal Monitor +
+
+ LEVEL +
+
+
+ 0 +
+
No signal
+
`; + monitor = container.querySelector('.sstv-signal-monitor'); + } + + const fill = monitor.querySelector('.sstv-signal-bar-fill'); + fill.style.width = level + '%'; + fill.style.background = barColor; + monitor.querySelector('.sstv-signal-status-text').textContent = statusText; + monitor.querySelector('.sstv-signal-level-value').textContent = level; + } + /** * Render decode progress in live area */ diff --git a/utils/sstv/sstv_decoder.py b/utils/sstv/sstv_decoder.py index c67d3c4..32e83bd 100644 --- a/utils/sstv/sstv_decoder.py +++ b/utils/sstv/sstv_decoder.py @@ -23,7 +23,7 @@ import numpy as np from utils.logging import get_logger from .constants import ISS_SSTV_FREQ, SAMPLE_RATE, SPEED_OF_LIGHT -from .dsp import normalize_audio +from .dsp import goertzel_mag, normalize_audio from .image_decoder import SSTVImageDecoder from .modes import get_mode from .vis import VISDetector @@ -92,6 +92,8 @@ class DecodeProgress: progress_percent: int = 0 message: str | None = None image: SSTVImage | None = None + signal_level: int = 0 # 0-100 RMS audio level + sstv_tone: str | None = None # 'leader', 'sync', 'pixel', None def to_dict(self) -> dict: result: dict = { @@ -105,6 +107,10 @@ class DecodeProgress: result['message'] = self.message if self.image: result['image'] = self.image.to_dict() + if self.signal_level > 0: + result['signal_level'] = self.signal_level + if self.sstv_tone: + result['sstv_tone'] = self.sstv_tone return result @@ -370,6 +376,7 @@ class SSTVDecoder: vis_detector = VISDetector(sample_rate=SAMPLE_RATE) image_decoder: SSTVImageDecoder | None = None current_mode_name: str | None = None + chunk_counter = 0 logger.info("Audio decode thread started") rtl_fm_error: str = '' @@ -402,6 +409,8 @@ class SSTVDecoder: raw_samples = np.frombuffer(raw_data[:n_samples * 2], dtype=np.int16) samples = normalize_audio(raw_samples) + chunk_counter += 1 + if image_decoder is not None: # Currently decoding an image complete = image_decoder.feed(samples) @@ -444,6 +453,31 @@ class SSTVDecoder: logger.warning(f"No mode spec for VIS code {vis_code}") vis_detector.reset() + # Emit signal level metrics every ~500ms (every 5th 100ms chunk) + if chunk_counter % 5 == 0 and image_decoder is None: + rms = float(np.sqrt(np.mean(samples ** 2))) + signal_level = min(100, int(rms * 500)) + + leader_energy = goertzel_mag(samples, 1900.0, SAMPLE_RATE) + sync_energy = goertzel_mag(samples, 1200.0, SAMPLE_RATE) + noise_floor = max(rms * 0.5, 0.001) + + if leader_energy > noise_floor * 5: + sstv_tone = 'leader' + elif sync_energy > noise_floor * 5: + sstv_tone = 'sync' + elif signal_level > 10: + sstv_tone = 'noise' + else: + sstv_tone = None + + self._emit_progress(DecodeProgress( + status='detecting', + message='Listening...', + signal_level=signal_level, + sstv_tone=sstv_tone, + )) + except Exception as e: logger.error(f"Error in decode thread: {e}") if not self._running: