diff --git a/routes/sstv.py b/routes/sstv.py index ed3676a..1029dec 100644 --- a/routes/sstv.py +++ b/routes/sstv.py @@ -15,14 +15,12 @@ from flask import Blueprint, jsonify, request, Response, send_file import app as app_module from utils.logging import get_logger -from utils.sse import format_sse -from utils.event_pipeline import process_event +from utils.sse import format_sse +from utils.event_pipeline import process_event from utils.sstv import ( get_sstv_decoder, is_sstv_available, ISS_SSTV_FREQ, - DecodeProgress, - DopplerInfo, ) logger = get_logger('intercept.sstv') @@ -36,14 +34,14 @@ _sstv_queue: queue.Queue = queue.Queue(maxsize=100) sstv_active_device: int | None = None -def _progress_callback(progress: DecodeProgress) -> None: - """Callback to queue progress updates for SSE stream.""" +def _progress_callback(data: dict) -> None: + """Callback to queue progress/scope updates for SSE stream.""" try: - _sstv_queue.put_nowait(progress.to_dict()) + _sstv_queue.put_nowait(data) except queue.Full: try: _sstv_queue.get_nowait() - _sstv_queue.put_nowait(progress.to_dict()) + _sstv_queue.put_nowait(data) except queue.Empty: pass @@ -399,14 +397,14 @@ def stream_progress(): keepalive_interval = 30.0 while True: - try: - progress = _sstv_queue.get(timeout=1) - last_keepalive = time.time() - try: - process_event('sstv', progress, progress.get('type')) - except Exception: - pass - yield format_sse(progress) + try: + progress = _sstv_queue.get(timeout=1) + last_keepalive = time.time() + try: + process_event('sstv', progress, progress.get('type')) + except Exception: + pass + yield format_sse(progress) except queue.Empty: now = time.time() if now - last_keepalive >= keepalive_interval: diff --git a/routes/sstv_general.py b/routes/sstv_general.py index 5ebcbb2..0ddcbfb 100644 --- a/routes/sstv_general.py +++ b/routes/sstv_general.py @@ -17,7 +17,6 @@ from utils.logging import get_logger from utils.sse import format_sse from utils.event_pipeline import process_event from utils.sstv import ( - DecodeProgress, get_general_sstv_decoder, ) @@ -49,14 +48,14 @@ SSTV_FREQUENCIES = [ _FREQ_MODULATION_MAP = {entry['frequency']: entry['modulation'] for entry in SSTV_FREQUENCIES} -def _progress_callback(progress: DecodeProgress) -> None: - """Callback to queue progress updates for SSE stream.""" +def _progress_callback(data: dict) -> None: + """Callback to queue progress/scope updates for SSE stream.""" try: - _sstv_general_queue.put_nowait(progress.to_dict()) + _sstv_general_queue.put_nowait(data) except queue.Full: try: _sstv_general_queue.get_nowait() - _sstv_general_queue.put_nowait(progress.to_dict()) + _sstv_general_queue.put_nowait(data) except queue.Empty: pass diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index 0b89efe..3419315 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -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 = ''; diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 6bafdb0..ed6d13e 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -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 = ''; diff --git a/templates/index.html b/templates/index.html index a2772b4..8b84a02 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2039,6 +2039,22 @@ + +
+