Add CW/Morse code decoder mode

New signal mode for decoding Morse code (CW) transmissions via SDR.
Includes route blueprint, utility decoder, frontend UI, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-25 20:58:48 +00:00
parent 4587b8ec7d
commit d3f2d5f15a
10 changed files with 1599 additions and 2 deletions
+33 -1
View File
@@ -80,7 +80,8 @@
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}"
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
morse: "{{ url_for('static', filename='css/modes/morse.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.INTERCEPT_MODE_STYLE_PROMISES = {};
@@ -271,6 +272,10 @@
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.5"/><path d="M2 21h20" opacity="0.3"/></svg></span>
<span class="mode-name">Waterfall</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('morse')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
<span class="mode-name">Morse</span>
</button>
</div>
</div>
@@ -675,6 +680,8 @@
{% include 'partials/modes/wefax.html' %}
{% include 'partials/modes/morse.html' %}
{% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/tscm.html' %}
@@ -3001,6 +3008,25 @@
</div>
</div>
<!-- Morse Code Decoder Visuals -->
<div id="morseVisuals" style="display: none; flex-direction: column; gap: 12px; padding: 16px; height: 100%;">
<div class="morse-toolbar">
<button class="btn btn-sm btn-outline" onclick="MorseMode.exportTxt()">Export TXT</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.exportCsv()">Export CSV</button>
<button class="btn btn-sm btn-outline" id="morseCopyBtn" onclick="MorseMode.copyToClipboard()">Copy</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.clearText()">Clear</button>
</div>
<div class="morse-scope-container">
<canvas id="morseScopeCanvas"></canvas>
</div>
<div id="morseDecodedText" class="morse-decoded-panel"></div>
<div class="morse-status-bar">
<span class="status-item" id="morseStatusBarWpm">15 WPM</span>
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
@@ -3125,6 +3151,7 @@
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/wefax.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/morse.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
@@ -3278,6 +3305,7 @@
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
};
const validModes = new Set(Object.keys(modeCatalog));
window.interceptModeCatalog = Object.assign({}, modeCatalog);
@@ -4077,6 +4105,7 @@
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
const morseVisuals = document.getElementById('morseVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
@@ -4094,6 +4123,7 @@
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
if (morseVisuals) morseVisuals.style.display = mode === 'morse' ? 'flex' : 'none';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
@@ -4252,6 +4282,8 @@
SpaceWeather.init();
} else if (mode === 'waterfall') {
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'morse') {
MorseMode.init();
}
// Destroy Waterfall WebSocket when leaving SDR receiver modes
+125
View File
@@ -0,0 +1,125 @@
<!-- MORSE CODE MODE -->
<div id="morseMode" class="mode-content">
<div class="section">
<h3>CW/Morse Decoder</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Decode CW (continuous wave) Morse code from amateur radio HF bands using USB demodulation
and Goertzel tone detection.
</p>
</div>
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="number" id="morseFrequency" value="14.060" step="0.001" min="1" max="30">
</div>
<div class="form-group">
<label>Band Presets</label>
<div class="morse-presets" style="display: flex; flex-wrap: wrap; gap: 4px;">
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(3.560)">80m</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(7.030)">40m</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(10.116)">30m</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(14.060)">20m</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(18.080)">17m</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(21.060)">15m</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(24.910)">12m</button>
<button class="btn btn-sm btn-outline" onclick="MorseMode.setFreq(28.060)">10m</button>
</div>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB)</label>
<input type="number" id="morseGain" value="40" step="1" min="0" max="50">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="number" id="morsePPM" value="0" step="1" min="-100" max="100">
</div>
<div class="form-group">
<label>SDR Type</label>
<select id="morseSdrType">
<option value="rtlsdr" selected>RTL-SDR</option>
<option value="hackrf">HackRF</option>
<option value="limesdr">LimeSDR</option>
<option value="airspy">Airspy</option>
<option value="sdrplay">SDRPlay</option>
</select>
</div>
<div class="form-group">
<label>Device Index</label>
<input type="number" id="morseDevice" value="0" step="1" min="0" max="9">
</div>
<div class="form-group" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="morseBiasT">
<label for="morseBiasT" style="margin: 0; cursor: pointer;">Bias-T Power</label>
</div>
</div>
<div class="section">
<h3>CW Settings</h3>
<div class="form-group">
<label>Tone Frequency: <span id="morseToneFreqLabel">700</span> Hz</label>
<input type="range" id="morseToneFreq" value="700" min="300" max="1200" step="10"
oninput="document.getElementById('morseToneFreqLabel').textContent = this.value">
</div>
<div class="form-group">
<label>Speed: <span id="morseWpmLabel">15</span> WPM</label>
<input type="range" id="morseWpm" value="15" min="5" max="50" step="1"
oninput="document.getElementById('morseWpmLabel').textContent = this.value">
</div>
</div>
<!-- Morse Reference -->
<div class="section">
<h3 style="cursor: pointer;" onclick="this.parentElement.querySelector('.morse-ref-grid').classList.toggle('collapsed')">
Morse Reference <span style="font-size: 10px; color: var(--text-dim);">(click to toggle)</span>
</h3>
<div class="morse-ref-grid collapsed" style="font-family: var(--font-mono); font-size: 10px; line-height: 1.8; columns: 2; column-gap: 12px; color: var(--text-dim);">
<div>A .-</div><div>B -...</div><div>C -.-.</div><div>D -..</div>
<div>E .</div><div>F ..-.</div><div>G --.</div><div>H ....</div>
<div>I ..</div><div>J .---</div><div>K -.-</div><div>L .-..</div>
<div>M --</div><div>N -.</div><div>O ---</div><div>P .--.</div>
<div>Q --.-</div><div>R .-.</div><div>S ...</div><div>T -</div>
<div>U ..-</div><div>V ...-</div><div>W .--</div><div>X -..-</div>
<div>Y -.--</div><div>Z --..</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">0 -----</div>
<div style="margin-top: 4px; border-top: 1px solid var(--border-color); padding-top: 4px;">1 .----</div>
<div>2 ..---</div><div>3 ...--</div><div>4 ....-</div>
<div>5 .....</div><div>6 -....</div><div>7 --...</div>
<div>8 ---..</div><div>9 ----.</div>
</div>
</div>
<!-- Status -->
<div class="section">
<div class="morse-status" style="display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim);">
<span id="morseStatusIndicator" class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);"></span>
<span id="morseStatusText">Standby</span>
<span style="margin-left: auto;" id="morseCharCount">0 chars</span>
</div>
</div>
<!-- Controls -->
<div class="section">
<div style="display: flex; gap: 8px;">
<button class="btn btn-primary" id="morseStartBtn" onclick="MorseMode.start()" style="flex: 1;">
Start Decoder
</button>
<button class="btn btn-danger" id="morseStopBtn" onclick="MorseMode.stop()" style="flex: 1; display: none;">
Stop
</button>
</div>
</div>
<!-- HF Antenna Note -->
<div class="section">
<p class="info-text" style="font-size: 11px; color: #ffaa00; line-height: 1.5;">
CW operates on HF bands (1-30 MHz). Requires an HF-capable SDR with direct sampling
or an upconverter, plus an appropriate HF antenna (dipole, end-fed, or random wire).
</p>
</div>
</div>
+2
View File
@@ -67,6 +67,7 @@
{{ mode_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mode_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mode_item('waterfall', 'Waterfall', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.4"/><path d="M2 21h20" opacity="0.2"/></svg>') }}
{{ mode_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
</div>
</div>
@@ -201,6 +202,7 @@
{{ mobile_item('sensor', '433MHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
{{ mobile_item('rtlamr', 'Meters', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>') }}
{{ mobile_item('subghz', 'SubGHz', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12h6l3-9 3 18 3-9h5"/></svg>') }}
{{ mobile_item('morse', 'Morse', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>') }}
{# Tracking #}
{{ mobile_item('adsb', 'Aircraft', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg>', '/adsb/dashboard') }}
{{ mobile_item('ais', 'Vessels', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg>', '/ais/dashboard') }}