mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Enable direct sampling (-D 2) for RTL-SDR at HF frequencies below 24 MHz so rtl_fm can actually receive CW signals. Add startup health check to detect immediate rtl_fm failures. Push stopped status event from decoder thread on EOF so the frontend auto-resets. Add frequency placeholder and help text. Fix stop button silently swallowing errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
/**
|
|
* 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('deviceSelect')?.value || '0',
|
|
sdr_type: document.getElementById('sdrTypeSelect')?.value || 'rtlsdr',
|
|
tone_freq: document.getElementById('morseToneFreq').value || '700',
|
|
wpm: document.getElementById('morseWpm').value || '15',
|
|
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
|
};
|
|
|
|
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 (err) {
|
|
console.error('Morse stop request failed:', err);
|
|
// Reset UI regardless so the user isn't stuck
|
|
state.running = false;
|
|
updateUI(false);
|
|
disconnectSSE();
|
|
stopScope();
|
|
});
|
|
}
|
|
|
|
// ---- 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';
|
|
var barChars = document.getElementById('morseStatusBarChars');
|
|
if (barChars) barChars.textContent = state.charCount + ' chars decoded';
|
|
}
|
|
|
|
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';
|
|
var barChars = document.getElementById('morseStatusBarChars');
|
|
if (barChars) barChars.textContent = '0 chars decoded';
|
|
}
|
|
|
|
// ---- 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 = 80 * dpr;
|
|
canvas.style.height = '80px';
|
|
|
|
scopeCtx = canvas.getContext('2d');
|
|
scopeCtx.scale(dpr, dpr);
|
|
scopeHistory = [];
|
|
|
|
var toneLabel = document.getElementById('morseScopeToneLabel');
|
|
var threshLabel = document.getElementById('morseScopeThreshLabel');
|
|
|
|
function draw() {
|
|
if (!scopeCtx) return;
|
|
var w = rect.width;
|
|
var h = 80;
|
|
|
|
scopeCtx.fillStyle = '#050510';
|
|
scopeCtx.fillRect(0, 0, w, h);
|
|
|
|
// Update header labels
|
|
if (toneLabel) toneLabel.textContent = scopeToneOn ? 'ON' : '--';
|
|
if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--';
|
|
|
|
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';
|
|
}
|
|
|
|
// Toggle scope and output panels (pager/sensor pattern)
|
|
var scopePanel = document.getElementById('morseScopePanel');
|
|
var outputPanel = document.getElementById('morseOutputPanel');
|
|
if (scopePanel) scopePanel.style.display = running ? 'block' : 'none';
|
|
if (outputPanel) outputPanel.style.display = running ? 'block' : 'none';
|
|
|
|
var scopeStatus = document.getElementById('morseScopeStatusLabel');
|
|
if (scopeStatus) scopeStatus.textContent = running ? 'ACTIVE' : 'IDLE';
|
|
if (scopeStatus) scopeStatus.style.color = running ? '#0f0' : '#444';
|
|
}
|
|
|
|
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,
|
|
};
|
|
})();
|