Files
intercept/static/js/modes/morse.js

1166 lines
41 KiB
JavaScript

/**
* Morse Code (CW) decoder mode.
* Lifecycle state machine: idle -> starting -> running -> stopping -> idle/error
*/
var MorseMode = (function () {
'use strict';
var SETTINGS_KEY = 'intercept.morse.settings.v3';
var STATUS_POLL_MS = 5000;
var LOCAL_STOP_TIMEOUT_MS = 2200;
var START_TIMEOUT_MS = 4000;
var state = {
initialized: false,
controlsBound: false,
lifecycle: 'idle',
eventSource: null,
statusPollTimer: null,
stopPromise: null,
startSeq: 0,
charCount: 0,
decodedLog: [], // { timestamp, morse, char }
rawLog: [],
waiting: false,
waitingStart: 0,
lastMetrics: {
wpm: 15,
tone_freq: 700,
level: 0,
threshold: 0,
noise_floor: 0,
stop_ms: null,
},
};
// Scope state
var scopeCtx = null;
var scopeAnim = null;
var scopeHistory = [];
var scopeThreshold = 0;
var scopeToneOn = false;
var scopeWaiting = false;
var waitingStart = 0;
var scopeRect = null;
var SCOPE_HISTORY_LEN = 300;
function el(id) {
return document.getElementById(id);
}
function notifyInfo(text) {
if (typeof showInfo === 'function') {
showInfo(text);
} else {
console.info(text);
}
}
function notifyError(text) {
if (typeof showError === 'function') {
showError(text);
} else {
alert(text);
}
}
function parseJsonSafe(response) {
return response.json().catch(function () { return {}; });
}
function postJson(url, payload, timeoutMs) {
var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
var timeoutId = controller ? setTimeout(function () { controller.abort(); }, timeoutMs) : null;
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {}),
signal: controller ? controller.signal : undefined,
}).then(function (response) {
return parseJsonSafe(response).then(function (data) {
if (!response.ok) {
var msg = data.message || data.error || ('HTTP ' + response.status);
throw new Error(msg);
}
return data;
});
}).finally(function () {
if (timeoutId) clearTimeout(timeoutId);
});
}
function collectConfig() {
return {
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
gain: (el('morseGain') && el('morseGain').value) || '40',
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
device: (el('deviceSelect') && el('deviceSelect').value) || '0',
sdr_type: (el('sdrTypeSelect') && el('sdrTypeSelect').value) || 'rtlsdr',
bias_t: (typeof getBiasTEnabled === 'function') ? getBiasTEnabled() : false,
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
tone_lock: !!(el('morseToneLock') && el('morseToneLock').checked),
threshold_mode: (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto',
manual_threshold: (el('morseManualThreshold') && el('morseManualThreshold').value) || '0',
threshold_multiplier: (el('morseThresholdMultiplier') && el('morseThresholdMultiplier').value) || '2.8',
threshold_offset: (el('morseThresholdOffset') && el('morseThresholdOffset').value) || '0',
signal_gate: (el('morseSignalGate') && el('morseSignalGate').value) || '0.05',
wpm_mode: (el('morseWpmMode') && el('morseWpmMode').value) || 'auto',
wpm: (el('morseWpm') && el('morseWpm').value) || '15',
wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
};
}
function persistSettings() {
try {
var payload = {
frequency: (el('morseFrequency') && el('morseFrequency').value) || '14.060',
gain: (el('morseGain') && el('morseGain').value) || '40',
ppm: (el('morsePPM') && el('morsePPM').value) || '0',
tone_freq: (el('morseToneFreq') && el('morseToneFreq').value) || '700',
bandwidth_hz: (el('morseBandwidth') && el('morseBandwidth').value) || '200',
auto_tone_track: !!(el('morseAutoToneTrack') && el('morseAutoToneTrack').checked),
tone_lock: !!(el('morseToneLock') && el('morseToneLock').checked),
threshold_mode: (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto',
manual_threshold: (el('morseManualThreshold') && el('morseManualThreshold').value) || '0',
threshold_multiplier: (el('morseThresholdMultiplier') && el('morseThresholdMultiplier').value) || '2.8',
threshold_offset: (el('morseThresholdOffset') && el('morseThresholdOffset').value) || '0',
signal_gate: (el('morseSignalGate') && el('morseSignalGate').value) || '0.05',
wpm_mode: (el('morseWpmMode') && el('morseWpmMode').value) || 'auto',
wpm: (el('morseWpm') && el('morseWpm').value) || '15',
wpm_lock: !!(el('morseWpmLock') && el('morseWpmLock').checked),
show_raw: !!(el('morseShowRaw') && el('morseShowRaw').checked),
show_diag: !!(el('morseShowDiag') && el('morseShowDiag').checked),
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(payload));
} catch (_) {
// Ignore local storage errors.
}
}
function applySettings(settings) {
if (!settings || typeof settings !== 'object') return;
if (el('morseFrequency') && settings.frequency !== undefined) el('morseFrequency').value = settings.frequency;
if (el('morseGain') && settings.gain !== undefined) el('morseGain').value = settings.gain;
if (el('morsePPM') && settings.ppm !== undefined) el('morsePPM').value = settings.ppm;
if (el('morseToneFreq') && settings.tone_freq !== undefined) el('morseToneFreq').value = settings.tone_freq;
if (el('morseBandwidth') && settings.bandwidth_hz !== undefined) el('morseBandwidth').value = settings.bandwidth_hz;
if (el('morseThresholdMode') && settings.threshold_mode !== undefined) el('morseThresholdMode').value = settings.threshold_mode;
if (el('morseManualThreshold') && settings.manual_threshold !== undefined) el('morseManualThreshold').value = settings.manual_threshold;
if (el('morseThresholdMultiplier') && settings.threshold_multiplier !== undefined) el('morseThresholdMultiplier').value = settings.threshold_multiplier;
if (el('morseThresholdOffset') && settings.threshold_offset !== undefined) el('morseThresholdOffset').value = settings.threshold_offset;
if (el('morseSignalGate') && settings.signal_gate !== undefined) el('morseSignalGate').value = settings.signal_gate;
if (el('morseWpmMode') && settings.wpm_mode !== undefined) el('morseWpmMode').value = settings.wpm_mode;
if (el('morseWpm') && settings.wpm !== undefined) el('morseWpm').value = settings.wpm;
if (el('morseAutoToneTrack') && settings.auto_tone_track !== undefined) el('morseAutoToneTrack').checked = !!settings.auto_tone_track;
if (el('morseToneLock') && settings.tone_lock !== undefined) el('morseToneLock').checked = !!settings.tone_lock;
if (el('morseWpmLock') && settings.wpm_lock !== undefined) el('morseWpmLock').checked = !!settings.wpm_lock;
if (el('morseShowRaw') && settings.show_raw !== undefined) el('morseShowRaw').checked = !!settings.show_raw;
if (el('morseShowDiag') && settings.show_diag !== undefined) el('morseShowDiag').checked = !!settings.show_diag;
updateToneLabel((el('morseToneFreq') && el('morseToneFreq').value) || '700');
updateWpmLabel((el('morseWpm') && el('morseWpm').value) || '15');
onThresholdModeChange();
onWpmModeChange();
toggleRawPanel();
toggleDiagPanel();
}
function loadSettings() {
try {
var raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) {
if (el('morseShowDiag')) el('morseShowDiag').checked = true;
toggleDiagPanel();
persistSettings();
return;
}
var parsed = JSON.parse(raw);
applySettings(parsed);
} catch (_) {
// Ignore malformed settings.
if (el('morseShowDiag')) el('morseShowDiag').checked = true;
toggleDiagPanel();
}
}
function bindControls() {
if (state.controlsBound) return;
state.controlsBound = true;
var ids = [
'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth',
'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold',
'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate',
'morseWpmMode', 'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag'
];
ids.forEach(function (id) {
var node = el(id);
if (!node) return;
node.addEventListener('change', persistSettings);
if (node.tagName === 'INPUT' && (node.type === 'range' || node.type === 'number' || node.type === 'text')) {
node.addEventListener('input', persistSettings);
}
});
if (el('morseShowRaw')) {
el('morseShowRaw').addEventListener('change', toggleRawPanel);
}
if (el('morseShowDiag')) {
el('morseShowDiag').addEventListener('change', toggleDiagPanel);
}
}
function setLifecycle(next) {
state.lifecycle = next;
updateUI();
}
function isTransition() {
return state.lifecycle === 'starting' || state.lifecycle === 'stopping';
}
function isActive() {
return state.lifecycle === 'starting' || state.lifecycle === 'running' || state.lifecycle === 'stopping';
}
function init() {
bindControls();
if (state.initialized) {
checkStatus();
return;
}
state.initialized = true;
loadSettings();
updateUI();
checkStatus();
if (!state.statusPollTimer) {
state.statusPollTimer = setInterval(checkStatus, STATUS_POLL_MS);
}
}
function destroy() {
if (state.statusPollTimer) {
clearInterval(state.statusPollTimer);
state.statusPollTimer = null;
}
if (state.lifecycle === 'running' || state.lifecycle === 'starting') {
stop({ silent: true }).catch(function () { });
} else {
disconnectSSE();
stopScope();
}
state.initialized = false;
}
function start() {
if (state.lifecycle === 'running' || state.lifecycle === 'starting') {
return Promise.resolve({ status: 'already_running' });
}
if (state.lifecycle === 'stopping' && state.stopPromise) {
return state.stopPromise.then(function () {
return start();
});
}
if (typeof checkDeviceAvailability === 'function' && !checkDeviceAvailability('morse')) {
return Promise.resolve({ status: 'blocked' });
}
clearDiagLog();
clearDecodedText();
clearRawText();
var payload = collectConfig();
persistSettings();
var seq = ++state.startSeq;
setLifecycle('starting');
return postJson('/morse/start', payload, START_TIMEOUT_MS)
.then(function (data) {
if (seq !== state.startSeq) {
return data;
}
if (data.status !== 'started') {
throw new Error(data.message || 'Failed to start Morse decoder');
}
if (typeof reserveDevice === 'function') {
var parsedDevice = Number(payload.device);
if (Number.isFinite(parsedDevice)) {
reserveDevice(parsedDevice, 'morse');
}
}
setLifecycle('running');
connectSSE();
startScope();
setStatusText('Listening');
applyMetrics(data.config || {}, true);
notifyInfo('Morse decoder started');
return data;
})
.catch(function (err) {
if (seq !== state.startSeq) {
return { status: 'stale' };
}
setLifecycle('error');
setStatusText('Error');
notifyError('Failed to start Morse decoder: ' + (err && err.message ? err.message : err));
setTimeout(function () {
if (state.lifecycle === 'error') {
setLifecycle('idle');
}
}, 800);
return { status: 'error', message: String(err && err.message ? err.message : err) };
});
}
function stop(options) {
options = options || {};
if (state.stopPromise) {
return state.stopPromise;
}
var currentlyActive = isActive();
if (!currentlyActive && !options.force) {
disconnectSSE();
stopScope();
setLifecycle('idle');
if (typeof releaseDevice === 'function') releaseDevice('morse');
return Promise.resolve({ status: 'not_running' });
}
state.startSeq += 1; // invalidate in-flight start responses
setLifecycle('stopping');
setStatusText('Stopping...');
disconnectSSE();
stopScope();
if (typeof releaseDevice === 'function') {
releaseDevice('morse');
}
var stopPromise;
if (options.skipRequest) {
stopPromise = Promise.resolve({ status: 'skipped' });
} else {
stopPromise = postJson('/morse/stop', {}, LOCAL_STOP_TIMEOUT_MS)
.catch(function (err) {
appendDiagLine('[stop] ' + (err && err.message ? err.message : err));
return { status: 'error', message: String(err && err.message ? err.message : err) };
});
}
state.stopPromise = stopPromise.then(function (data) {
if (data && data.stop_ms !== undefined) {
state.lastMetrics.stop_ms = Number(data.stop_ms);
updateMetricLabel('morseMetricStopMs', 'STOP ' + Math.round(state.lastMetrics.stop_ms) + ' ms');
}
if (data && Array.isArray(data.cleanup_steps)) {
appendDiagLine('[stop] ' + data.cleanup_steps.join(' | '));
}
if (data && Array.isArray(data.alive) && data.alive.length) {
appendDiagLine('[stop] still alive: ' + data.alive.join(', '));
}
setLifecycle('idle');
setStatusText('Standby');
return data;
}).finally(function () {
state.stopPromise = null;
});
return state.stopPromise;
}
function checkStatus() {
if (!state.initialized) return;
fetch('/morse/status')
.then(function (r) { return parseJsonSafe(r); })
.then(function (data) {
if (!data || typeof data !== 'object') return;
if (data.running) {
if (data.state === 'starting') {
setLifecycle('starting');
} else if (data.state === 'stopping') {
setLifecycle('stopping');
} else {
setLifecycle('running');
}
if (!state.eventSource) connectSSE();
if (!scopeAnim && state.lifecycle === 'running') startScope();
var message = data.message || (state.lifecycle === 'running' ? 'Listening' : data.state);
setStatusText(message);
if (data.config) {
applyMetrics(data.config, true);
}
} else if (state.lifecycle === 'running' || state.lifecycle === 'starting' || state.lifecycle === 'stopping') {
disconnectSSE();
stopScope();
setLifecycle('idle');
setStatusText('Standby');
if (typeof releaseDevice === 'function') {
releaseDevice('morse');
}
}
if (data.error) {
appendDiagLine('[status] ' + data.error);
}
})
.catch(function () {
// Ignore status polling errors.
});
}
function connectSSE() {
disconnectSSE();
var es = new EventSource('/morse/stream');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
handleMessage(msg);
} catch (_) {
// Ignore malformed events.
}
};
es.onerror = function () {
if (state.lifecycle === 'running') {
appendDiagLine('[stream] reconnecting...');
}
};
state.eventSource = es;
}
function disconnectSSE() {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}
function handleMessage(msg) {
if (!msg || typeof msg !== 'object') return;
var type = msg.type;
if (type === 'scope') {
handleScope(msg);
applyMetrics(msg, false);
return;
}
if (type === 'morse_char') {
appendChar(msg.char, msg.morse, msg.timestamp || '--:--:--');
return;
}
if (type === 'morse_space') {
appendSpace();
appendRawToken(' // ');
return;
}
if (type === 'morse_element') {
appendRawToken(msg.element || '');
return;
}
if (type === 'morse_gap') {
if (msg.gap === 'char') {
appendRawToken(' / ');
} else if (msg.gap === 'word') {
appendRawToken(' // ');
}
return;
}
if (type === 'status') {
handleStatus(msg);
return;
}
if (type === 'info') {
appendDiagLine(msg.text || '[info]');
return;
}
if (type === 'error') {
appendDiagLine('[error] ' + (msg.text || 'Decoder error'));
return;
}
}
function handleStatus(msg) {
var stateValue = String(msg.state || msg.status || '').toLowerCase();
if (stateValue === 'starting') {
setLifecycle('starting');
setStatusText('Starting...');
} else if (stateValue === 'running') {
setLifecycle('running');
setStatusText('Listening');
} else if (stateValue === 'stopping') {
setLifecycle('stopping');
setStatusText('Stopping...');
}
if (msg.metrics) {
applyMetrics(msg.metrics, false);
}
if (msg.stop_ms !== undefined) {
state.lastMetrics.stop_ms = Number(msg.stop_ms);
updateMetricLabel('morseMetricStopMs', 'STOP ' + Math.round(state.lastMetrics.stop_ms) + ' ms');
}
if (msg.cleanup_steps && Array.isArray(msg.cleanup_steps)) {
appendDiagLine('[cleanup] ' + msg.cleanup_steps.join(' | '));
}
if (msg.alive && Array.isArray(msg.alive) && msg.alive.length) {
appendDiagLine('[cleanup] alive: ' + msg.alive.join(', '));
}
if (msg.status === 'stopped' || stateValue === 'idle') {
disconnectSSE();
stopScope();
setLifecycle('idle');
setStatusText('Standby');
if (typeof releaseDevice === 'function') {
releaseDevice('morse');
}
}
}
function handleScope(msg) {
var amps = Array.isArray(msg.amplitudes) ? msg.amplitudes : [];
if (msg.waiting && amps.length === 0) {
if (!scopeWaiting) {
scopeWaiting = true;
waitingStart = Date.now();
appendDiagLine('[morse] waiting for PCM stream...');
}
var waitElapsedMs = waitingStart ? (Date.now() - waitingStart) : 0;
if (waitElapsedMs > 10000 && el('morseDiagLog') && el('morseDiagLog').children.length < 6) {
appendDiagLine('[hint] No samples after 10s. Check SDR device, frequency, and HF direct sampling path.');
}
} else if (amps.length > 0) {
scopeWaiting = false;
waitingStart = 0;
}
for (var i = 0; i < amps.length; i++) {
scopeHistory.push(amps[i]);
if (scopeHistory.length > SCOPE_HISTORY_LEN) {
scopeHistory.shift();
}
}
scopeThreshold = Number(msg.threshold) || 0;
scopeToneOn = !!msg.tone_on;
if (msg.tone_freq !== undefined) {
state.lastMetrics.tone_freq = Number(msg.tone_freq) || state.lastMetrics.tone_freq;
}
if (msg.wpm !== undefined) {
state.lastMetrics.wpm = Number(msg.wpm) || state.lastMetrics.wpm;
}
}
function applyMetrics(metrics, fromConfig) {
if (!metrics || typeof metrics !== 'object') return;
if (metrics.wpm !== undefined) {
state.lastMetrics.wpm = Number(metrics.wpm) || state.lastMetrics.wpm;
}
if (metrics.tone_freq !== undefined) {
state.lastMetrics.tone_freq = Number(metrics.tone_freq) || state.lastMetrics.tone_freq;
}
if (metrics.level !== undefined) {
state.lastMetrics.level = Number(metrics.level) || 0;
}
if (metrics.threshold !== undefined) {
state.lastMetrics.threshold = Number(metrics.threshold) || 0;
} else if (fromConfig && metrics.manual_threshold !== undefined) {
state.lastMetrics.threshold = Number(metrics.manual_threshold) || state.lastMetrics.threshold;
}
if (metrics.noise_floor !== undefined) {
state.lastMetrics.noise_floor = Number(metrics.noise_floor) || 0;
}
updateMetricLabel('morseMetricTone', 'TONE ' + Math.round(state.lastMetrics.tone_freq || 700) + ' Hz');
updateMetricLabel('morseMetricLevel', 'LEVEL ' + (state.lastMetrics.level || 0).toFixed(2));
updateMetricLabel('morseMetricThreshold', 'THRESH ' + (state.lastMetrics.threshold || 0).toFixed(2));
updateMetricLabel('morseMetricNoise', 'NOISE ' + (state.lastMetrics.noise_floor || 0).toFixed(2));
var toneScope = el('morseScopeToneLabel');
if (toneScope) {
toneScope.textContent = scopeToneOn ? 'ON' : '--';
}
var thresholdScope = el('morseScopeThreshLabel');
if (thresholdScope) {
thresholdScope.textContent = state.lastMetrics.threshold > 0
? Math.round(state.lastMetrics.threshold)
: '--';
}
var barWpm = el('morseStatusBarWpm');
if (barWpm) barWpm.textContent = Math.round(state.lastMetrics.wpm || 0) + ' WPM';
var barTone = el('morseStatusBarTone');
if (barTone) barTone.textContent = Math.round(state.lastMetrics.tone_freq || 700) + ' Hz';
var metricState = el('morseMetricState');
if (metricState) metricState.textContent = 'STATE ' + state.lifecycle;
}
function appendChar(ch, morse, timestamp) {
if (!ch) return;
state.charCount += 1;
state.decodedLog.push({
timestamp: timestamp || '--:--:--',
morse: morse || '',
char: ch,
});
var panel = el('morseDecodedText');
if (panel) {
var span = document.createElement('span');
span.className = 'morse-char';
span.textContent = ch;
span.title = (morse || '') + ' (' + (timestamp || '--:--:--') + ')';
panel.appendChild(span);
panel.scrollTop = panel.scrollHeight;
}
updateCharCounts();
}
function appendSpace() {
var panel = el('morseDecodedText');
if (!panel) return;
var span = document.createElement('span');
span.className = 'morse-word-space';
span.textContent = ' ';
panel.appendChild(span);
panel.scrollTop = panel.scrollHeight;
}
function appendRawToken(token) {
if (!token) return;
state.rawLog.push(token);
if (state.rawLog.length > 2000) {
state.rawLog.splice(0, state.rawLog.length - 2000);
}
var rawText = el('morseRawText');
if (rawText) {
rawText.textContent = state.rawLog.join('');
rawText.scrollTop = rawText.scrollHeight;
}
}
function clearRawText() {
state.rawLog = [];
var rawText = el('morseRawText');
if (rawText) rawText.textContent = '';
}
function updateCharCounts() {
var countEl = el('morseCharCount');
if (countEl) countEl.textContent = state.charCount + ' chars';
var barChars = el('morseStatusBarChars');
if (barChars) barChars.textContent = state.charCount + ' chars decoded';
}
function clearDecodedText() {
state.charCount = 0;
state.decodedLog = [];
var panel = el('morseDecodedText');
if (panel) panel.innerHTML = '';
updateCharCounts();
}
function startScope() {
var canvas = el('morseScopeCanvas');
if (!canvas) return;
var rect = canvas.getBoundingClientRect();
if (!rect.width) return;
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(80 * dpr));
canvas.style.height = '80px';
scopeCtx = canvas.getContext('2d');
if (!scopeCtx) return;
scopeCtx.setTransform(1, 0, 0, 1, 0, 0);
scopeCtx.scale(dpr, dpr);
scopeHistory = [];
scopeRect = rect;
if (scopeAnim) {
cancelAnimationFrame(scopeAnim);
scopeAnim = null;
}
function draw() {
if (!scopeCtx || !scopeRect) return;
var w = scopeRect.width;
var h = 80;
scopeCtx.fillStyle = '#050510';
scopeCtx.fillRect(0, 0, w, h);
if (scopeHistory.length === 0) {
if (scopeWaiting) {
var elapsed = waitingStart ? (Date.now() - waitingStart) / 1000 : 0;
var text = elapsed > 10 ? 'No audio data - check SDR log below' : 'Awaiting SDR data...';
scopeCtx.fillStyle = elapsed > 10 ? '#887744' : '#556677';
scopeCtx.font = '12px monospace';
scopeCtx.textAlign = 'center';
scopeCtx.fillText(text, w / 2, h / 2);
scopeCtx.textAlign = 'start';
}
scopeAnim = requestAnimationFrame(draw);
return;
}
var maxVal = 0;
for (var i = 0; i < scopeHistory.length; i++) {
if (scopeHistory[i] > maxVal) maxVal = scopeHistory[i];
}
if (maxVal <= 0) maxVal = 1;
var barWidth = w / SCOPE_HISTORY_LEN;
var thresholdNorm = scopeThreshold / maxVal;
for (var j = 0; j < scopeHistory.length; j++) {
var norm = scopeHistory[j] / maxVal;
var barHeight = norm * (h - 10);
var x = j * barWidth;
var y = h - barHeight;
scopeCtx.fillStyle = scopeHistory[j] > scopeThreshold ? '#00ff88' : '#334455';
scopeCtx.fillRect(x, y, Math.max(barWidth - 1, 1), barHeight);
}
if (scopeThreshold > 0) {
var yThresh = h - (thresholdNorm * (h - 10));
scopeCtx.strokeStyle = '#ff4444';
scopeCtx.lineWidth = 1;
scopeCtx.setLineDash([4, 4]);
scopeCtx.beginPath();
scopeCtx.moveTo(0, yThresh);
scopeCtx.lineTo(w, yThresh);
scopeCtx.stroke();
scopeCtx.setLineDash([]);
}
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;
scopeRect = null;
scopeHistory = [];
scopeWaiting = false;
waitingStart = 0;
}
function appendDiagLine(text) {
var log = el('morseDiagLog');
if (!log) return;
var showDiag = !!(el('morseShowDiag') && el('morseShowDiag').checked);
if (!showDiag && scopeWaiting) {
showDiag = true;
}
if (!showDiag) return;
log.style.display = 'block';
var line = document.createElement('div');
line.textContent = text;
log.appendChild(line);
while (log.children.length > 32) {
log.removeChild(log.firstChild);
}
log.scrollTop = log.scrollHeight;
}
function clearDiagLog() {
var log = el('morseDiagLog');
if (!log) return;
log.innerHTML = '';
log.style.display = 'none';
}
function toggleDiagPanel() {
var log = el('morseDiagLog');
if (!log) return;
var showDiag = !!(el('morseShowDiag') && el('morseShowDiag').checked);
if (!showDiag) {
log.style.display = 'none';
} else if (log.children.length > 0) {
log.style.display = 'block';
}
}
function toggleRawPanel() {
var panel = el('morseRawPanel');
if (!panel) return;
var showRaw = !!(el('morseShowRaw') && el('morseShowRaw').checked);
panel.style.display = showRaw ? 'block' : 'none';
}
function setStatusText(text) {
var statusText = el('morseStatusText');
if (statusText) statusText.textContent = text;
}
function updateMetricLabel(id, text) {
var node = el(id);
if (node) node.textContent = text;
}
function updateUI() {
var startBtn = el('morseStartBtn');
var stopBtn = el('morseStopBtn');
var indicator = el('morseStatusIndicator');
var running = state.lifecycle === 'running';
var starting = state.lifecycle === 'starting';
var stopping = state.lifecycle === 'stopping';
var busy = isTransition();
if (startBtn) {
startBtn.style.display = running || starting ? 'none' : 'block';
startBtn.disabled = busy;
}
if (stopBtn) {
stopBtn.style.display = (running || starting || stopping) ? 'block' : 'none';
stopBtn.disabled = stopping;
stopBtn.textContent = stopping ? 'Stopping...' : 'Stop Decoder';
}
if (indicator) {
if (running) {
indicator.style.background = '#00ff88';
} else if (starting || stopping) {
indicator.style.background = '#ffaa00';
} else if (state.lifecycle === 'error') {
indicator.style.background = '#ff5555';
} else {
indicator.style.background = 'var(--text-dim)';
}
}
if (state.lifecycle === 'idle') setStatusText('Standby');
if (state.lifecycle === 'starting') setStatusText('Starting...');
if (state.lifecycle === 'running') setStatusText('Listening');
if (state.lifecycle === 'stopping') setStatusText('Stopping...');
if (state.lifecycle === 'error') setStatusText('Error');
var scopePanel = el('morseScopePanel');
if (scopePanel) scopePanel.style.display = (running || starting) ? 'block' : 'none';
var outputPanel = el('morseOutputPanel');
if (outputPanel) outputPanel.style.display = (running || starting) ? 'block' : 'none';
var scopeStatus = el('morseScopeStatusLabel');
if (scopeStatus) {
if (running) {
scopeStatus.textContent = 'ACTIVE';
scopeStatus.style.color = '#0f0';
} else if (starting) {
scopeStatus.textContent = 'STARTING';
scopeStatus.style.color = '#ffaa00';
} else if (stopping) {
scopeStatus.textContent = 'STOPPING';
scopeStatus.style.color = '#ffaa00';
} else {
scopeStatus.textContent = 'IDLE';
scopeStatus.style.color = '#444';
}
}
var stateBar = el('morseStatusBarState');
if (stateBar) {
stateBar.textContent = state.lifecycle.toUpperCase();
}
var metricState = el('morseMetricState');
if (metricState) {
metricState.textContent = 'STATE ' + state.lifecycle;
}
var controls = [
'morseFrequency', 'morseGain', 'morsePPM', 'morseToneFreq', 'morseBandwidth',
'morseAutoToneTrack', 'morseToneLock', 'morseThresholdMode', 'morseManualThreshold',
'morseThresholdMultiplier', 'morseThresholdOffset', 'morseSignalGate', 'morseWpmMode',
'morseWpm', 'morseWpmLock', 'morseShowRaw', 'morseShowDiag',
'morseCalibrateBtn', 'morseDecodeFileBtn', 'morseFileInput'
];
controls.forEach(function (id) {
var node = el(id);
if (!node) return;
node.disabled = busy;
});
toggleRawPanel();
toggleDiagPanel();
}
function updateToneLabel(value) {
var toneLabel = el('morseToneFreqLabel');
if (toneLabel) toneLabel.textContent = String(value);
persistSettings();
}
function updateWpmLabel(value) {
var wpmLabel = el('morseWpmLabel');
if (wpmLabel) wpmLabel.textContent = String(value);
persistSettings();
}
function onThresholdModeChange() {
var mode = (el('morseThresholdMode') && el('morseThresholdMode').value) || 'auto';
var manualRow = el('morseManualThresholdRow');
var autoRow = el('morseThresholdAutoRow');
var offsetRow = el('morseThresholdOffsetRow');
if (manualRow) manualRow.style.display = mode === 'manual' ? 'block' : 'none';
if (autoRow) autoRow.style.display = mode === 'manual' ? 'none' : 'block';
if (offsetRow) offsetRow.style.display = mode === 'manual' ? 'none' : 'block';
persistSettings();
}
function onWpmModeChange() {
var mode = (el('morseWpmMode') && el('morseWpmMode').value) || 'auto';
var manualRow = el('morseWpmManualRow');
if (manualRow) {
manualRow.style.display = mode === 'manual' ? 'block' : 'none';
}
persistSettings();
}
function setFreq(mhz) {
var freq = el('morseFrequency');
if (freq) {
freq.value = String(mhz);
persistSettings();
}
}
function exportTxt() {
var text = state.decodedLog.map(function (entry) { return entry.char; }).join('');
downloadFile('morse_decoded.txt', text, 'text/plain');
}
function exportCsv() {
var lines = ['timestamp,morse,character'];
state.decodedLog.forEach(function (entry) {
lines.push(entry.timestamp + ',"' + entry.morse + '",' + entry.char);
});
downloadFile('morse_decoded.csv', lines.join('\n'), 'text/csv');
}
function copyToClipboard() {
var text = state.decodedLog.map(function (entry) { return entry.char; }).join('');
if (!navigator.clipboard || !navigator.clipboard.writeText) return;
navigator.clipboard.writeText(text).then(function () {
var btn = el('morseCopyBtn');
if (!btn) return;
var original = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(function () {
btn.textContent = original;
}, 1200);
}).catch(function () {
// Ignore clipboard failures.
});
}
function downloadFile(filename, content, type) {
var blob = new Blob([content], { type: type });
var url = URL.createObjectURL(blob);
var anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function calibrate() {
if (state.lifecycle !== 'running') {
notifyInfo('Morse decoder is not running');
return;
}
postJson('/morse/calibrate', {}, 2000)
.then(function () {
appendDiagLine('[calibrate] estimator reset requested');
notifyInfo('Morse estimator reset');
})
.catch(function (err) {
notifyError('Calibration failed: ' + (err && err.message ? err.message : err));
});
}
function decodeFile() {
var input = el('morseFileInput');
if (!input || !input.files || !input.files[0]) {
notifyError('Select a WAV file first.');
return;
}
var file = input.files[0];
var config = collectConfig();
var formData = new FormData();
formData.append('audio', file);
formData.append('tone_freq', config.tone_freq);
formData.append('wpm', config.wpm);
formData.append('bandwidth_hz', config.bandwidth_hz);
formData.append('auto_tone_track', String(config.auto_tone_track));
formData.append('tone_lock', String(config.tone_lock));
formData.append('threshold_mode', config.threshold_mode);
formData.append('manual_threshold', config.manual_threshold);
formData.append('threshold_multiplier', config.threshold_multiplier);
formData.append('threshold_offset', config.threshold_offset);
formData.append('wpm_mode', config.wpm_mode);
formData.append('wpm_lock', String(config.wpm_lock));
formData.append('signal_gate', config.signal_gate);
var decodeBtn = el('morseDecodeFileBtn');
if (decodeBtn) {
decodeBtn.disabled = true;
decodeBtn.textContent = 'Decoding...';
}
fetch('/morse/decode-file', {
method: 'POST',
body: formData,
}).then(function (response) {
return parseJsonSafe(response).then(function (data) {
if (!response.ok || data.status !== 'ok') {
throw new Error(data.message || ('HTTP ' + response.status));
}
clearDecodedText();
clearRawText();
var text = String(data.text || '');
var raw = String(data.raw || '');
if (text.length > 0) {
for (var i = 0; i < text.length; i++) {
if (text[i] === ' ') {
appendSpace();
} else {
appendChar(text[i], '', '--:--:--');
}
}
}
if (raw) {
state.rawLog = [raw];
var rawText = el('morseRawText');
if (rawText) rawText.textContent = raw;
}
if (data.metrics) {
applyMetrics(data.metrics, false);
}
toggleRawPanel();
notifyInfo('File decode complete: ' + (data.char_count || 0) + ' chars');
});
}).catch(function (err) {
notifyError('WAV decode failed: ' + (err && err.message ? err.message : err));
}).finally(function () {
if (decodeBtn) {
decodeBtn.disabled = false;
decodeBtn.textContent = 'Decode File';
}
});
}
return {
init: init,
destroy: destroy,
start: start,
stop: stop,
setFreq: setFreq,
exportTxt: exportTxt,
exportCsv: exportCsv,
copyToClipboard: copyToClipboard,
clearText: clearDecodedText,
calibrate: calibrate,
decodeFile: decodeFile,
updateToneLabel: updateToneLabel,
updateWpmLabel: updateWpmLabel,
onThresholdModeChange: onThresholdModeChange,
onWpmModeChange: onWpmModeChange,
isActive: isActive,
};
})();