/** * Morse Code (CW) decoder module. * * IIFE providing start/stop controls, SSE streaming, scope canvas, * decoded text display, and export capabilities. */ var MorseMode = (function () { 'use strict'; var state = { running: false, initialized: false, eventSource: null, charCount: 0, decodedLog: [], // { timestamp, morse, char } }; // Scope state var scopeCtx = null; var scopeAnim = null; var scopeHistory = []; var SCOPE_HISTORY_LEN = 300; var scopeThreshold = 0; var scopeToneOn = false; // ---- Initialization ---- function init() { if (state.initialized) { checkStatus(); return; } state.initialized = true; checkStatus(); } function destroy() { disconnectSSE(); stopScope(); } // ---- Status ---- function checkStatus() { fetch('/morse/status') .then(function (r) { return r.json(); }) .then(function (data) { if (data.running) { state.running = true; updateUI(true); connectSSE(); startScope(); } else { state.running = false; updateUI(false); } }) .catch(function () {}); } // ---- Start / Stop ---- function start() { if (state.running) return; var payload = { frequency: document.getElementById('morseFrequency').value || '14.060', gain: document.getElementById('morseGain').value || '0', ppm: document.getElementById('morsePPM').value || '0', device: document.getElementById('morseDevice').value || '0', sdr_type: document.getElementById('morseSdrType').value || 'rtlsdr', tone_freq: document.getElementById('morseToneFreq').value || '700', wpm: document.getElementById('morseWpm').value || '15', bias_t: document.getElementById('morseBiasT').checked, }; fetch('/morse/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) .then(function (r) { return r.json(); }) .then(function (data) { if (data.status === 'started') { state.running = true; state.charCount = 0; state.decodedLog = []; updateUI(true); connectSSE(); startScope(); clearDecodedText(); } else { alert('Error: ' + (data.message || 'Unknown error')); } }) .catch(function (err) { alert('Failed to start Morse decoder: ' + err); }); } function stop() { fetch('/morse/stop', { method: 'POST' }) .then(function (r) { return r.json(); }) .then(function () { state.running = false; updateUI(false); disconnectSSE(); stopScope(); }) .catch(function () {}); } // ---- SSE ---- function connectSSE() { disconnectSSE(); var es = new EventSource('/morse/stream'); es.onmessage = function (e) { try { var msg = JSON.parse(e.data); handleMessage(msg); } catch (_) {} }; es.onerror = function () { // Reconnect handled by browser }; state.eventSource = es; } function disconnectSSE() { if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } } function handleMessage(msg) { var type = msg.type; if (type === 'scope') { // Update scope data var amps = msg.amplitudes || []; for (var i = 0; i < amps.length; i++) { scopeHistory.push(amps[i]); if (scopeHistory.length > SCOPE_HISTORY_LEN) { scopeHistory.shift(); } } scopeThreshold = msg.threshold || 0; scopeToneOn = msg.tone_on || false; } else if (type === 'morse_char') { appendChar(msg.char, msg.morse, msg.timestamp); } else if (type === 'morse_space') { appendSpace(); } else if (type === 'status') { if (msg.status === 'stopped') { state.running = false; updateUI(false); disconnectSSE(); stopScope(); } } else if (type === 'error') { console.error('Morse error:', msg.text); } } // ---- Decoded text ---- function appendChar(ch, morse, timestamp) { state.charCount++; state.decodedLog.push({ timestamp: timestamp, morse: morse, char: ch }); var panel = document.getElementById('morseDecodedText'); if (!panel) return; var span = document.createElement('span'); span.className = 'morse-char'; span.textContent = ch; span.title = morse + ' (' + timestamp + ')'; panel.appendChild(span); // Auto-scroll panel.scrollTop = panel.scrollHeight; // Update count var countEl = document.getElementById('morseCharCount'); if (countEl) countEl.textContent = state.charCount + ' chars'; } function appendSpace() { var panel = document.getElementById('morseDecodedText'); if (!panel) return; var span = document.createElement('span'); span.className = 'morse-word-space'; span.textContent = ' '; panel.appendChild(span); } function clearDecodedText() { var panel = document.getElementById('morseDecodedText'); if (panel) panel.innerHTML = ''; state.charCount = 0; state.decodedLog = []; var countEl = document.getElementById('morseCharCount'); if (countEl) countEl.textContent = '0 chars'; } // ---- Scope canvas ---- function startScope() { var canvas = document.getElementById('morseScopeCanvas'); if (!canvas) return; var dpr = window.devicePixelRatio || 1; var rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = 120 * dpr; canvas.style.height = '120px'; scopeCtx = canvas.getContext('2d'); scopeCtx.scale(dpr, dpr); scopeHistory = []; function draw() { if (!scopeCtx) return; var w = rect.width; var h = 120; scopeCtx.fillStyle = '#0a0e14'; scopeCtx.fillRect(0, 0, w, h); if (scopeHistory.length === 0) { scopeAnim = requestAnimationFrame(draw); return; } // Find max for normalization var maxVal = 0; for (var i = 0; i < scopeHistory.length; i++) { if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i]; } if (maxVal === 0) maxVal = 1; var barW = w / SCOPE_HISTORY_LEN; var threshNorm = scopeThreshold / maxVal; // Draw amplitude bars for (var j = 0; j < scopeHistory.length; j++) { var norm = scopeHistory[j] / maxVal; var barH = norm * (h - 10); var x = j * barW; var y = h - barH; // Green if above threshold, gray if below if (scopeHistory[j] > scopeThreshold) { scopeCtx.fillStyle = '#00ff88'; } else { scopeCtx.fillStyle = '#334455'; } scopeCtx.fillRect(x, y, Math.max(barW - 1, 1), barH); } // Draw threshold line if (scopeThreshold > 0) { var threshY = h - (threshNorm * (h - 10)); scopeCtx.strokeStyle = '#ff4444'; scopeCtx.lineWidth = 1; scopeCtx.setLineDash([4, 4]); scopeCtx.beginPath(); scopeCtx.moveTo(0, threshY); scopeCtx.lineTo(w, threshY); scopeCtx.stroke(); scopeCtx.setLineDash([]); } // Tone indicator if (scopeToneOn) { scopeCtx.fillStyle = '#00ff88'; scopeCtx.beginPath(); scopeCtx.arc(w - 12, 12, 5, 0, Math.PI * 2); scopeCtx.fill(); } scopeAnim = requestAnimationFrame(draw); } draw(); } function stopScope() { if (scopeAnim) { cancelAnimationFrame(scopeAnim); scopeAnim = null; } scopeCtx = null; } // ---- Export ---- function exportTxt() { var text = state.decodedLog.map(function (e) { return e.char; }).join(''); downloadFile('morse_decoded.txt', text, 'text/plain'); } function exportCsv() { var lines = ['timestamp,morse,character']; state.decodedLog.forEach(function (e) { lines.push(e.timestamp + ',"' + e.morse + '",' + e.char); }); downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv'); } function copyToClipboard() { var text = state.decodedLog.map(function (e) { return e.char; }).join(''); navigator.clipboard.writeText(text).then(function () { var btn = document.getElementById('morseCopyBtn'); if (btn) { var orig = btn.textContent; btn.textContent = 'Copied!'; setTimeout(function () { btn.textContent = orig; }, 1500); } }); } function downloadFile(filename, content, type) { var blob = new Blob([content], { type: type }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } // ---- UI ---- function updateUI(running) { var startBtn = document.getElementById('morseStartBtn'); var stopBtn = document.getElementById('morseStopBtn'); var indicator = document.getElementById('morseStatusIndicator'); var statusText = document.getElementById('morseStatusText'); if (startBtn) startBtn.style.display = running ? 'none' : ''; if (stopBtn) stopBtn.style.display = running ? '' : 'none'; if (indicator) { indicator.style.background = running ? '#00ff88' : 'var(--text-dim)'; } if (statusText) { statusText.textContent = running ? 'Listening' : 'Standby'; } } function setFreq(mhz) { var el = document.getElementById('morseFrequency'); if (el) el.value = mhz; } // ---- Public API ---- return { init: init, destroy: destroy, start: start, stop: stop, setFreq: setFreq, exportTxt: exportTxt, exportCsv: exportCsv, copyToClipboard: copyToClipboard, clearText: clearDecodedText, }; })();