Unattended Morse code (CW) recorder that monitors HF amateur radio bands via SDR. Automatically detects CW signals, validates them against non-CW interference, estimates character speed, and saves recordings as MP3 files with JSON metadata. Supports multiple SDR hardware via GNU Radio.
- Wideband monitoring - scans 200 kHz of bandwidth to find CW signals automatically
- Ham band presets -
--band 20msets center frequency and scan limits for any HF band - CW validation - bimodal amplitude, Morse timing, and dit/dah ratio tests reject SSB, digital, and noise
- Post-recording audio analysis - tone SNR and on/off contrast filter catches false positives
- Morse timing estimation - K-means clustering of dit/dah durations, Farnsworth spacing detection
- FT8 exclusion - automatically skips FT8 frequency windows on all bands
- GUI waterfall - real-time pyqtgraph waterfall display with signal markers and scan region overlay
- Terminal display - flicker-free fixed-height status display with signal tracking
- Streaming audio - phase-coherent narrowband extraction with multi-stage IIR decimation, no clicks
- Smart recording - 3-second lead-in capture, mid-QSO silence preservation, 2-second trailing trim
- Persistent numbering - recording file numbers continue across sessions with no gaps
| Device | device_type |
Notes |
|---|---|---|
| SDRplay RSPdx | rspdx |
Default. Two-stage gain, 3 antenna ports. Requires SDRplay API v3.x |
| RTL-SDR | rtlsdr |
Cheap, widely available. 8-bit, ~2 MHz max bandwidth |
| HackRF One | hackrf |
Wideband, 8-bit. Good for experimentation |
| LimeSDR | lime |
12-bit, flexible sample rates |
| ADALM-PlutoSDR | pluto |
Compact AD9361-based. USB or network |
| Ettus USRP | usrp |
Professional grade. Requires UHD drivers |
| Generic SoapySDR | soapy |
Any SoapySDR-supported device. Set soapy_driver |
- One of the supported SDRs listed above
- Windows 10/11 (tested on Windows 10 Pro)
- SDRplay API v3.x (only if using RSPdx)
- Radioconda (provides GNU Radio, gr-sdrplay3, SoapySDR, UHD)
Download and install the SDRplay API (v3.x) from sdrplay.com/downloads. This provides the driver for the RSPdx. Reboot after installation.
Download the radioconda installer for Windows from github.com/ryanvolz/radioconda. Run the .exe installer.
During installation:
- Install for "Just Me" (recommended)
- Add to PATH when prompted
- Default install location is fine (
C:\Users\<you>\radioconda)
Open a Radioconda Prompt from the Start Menu:
conda install -y -c conda-forge pysoundfile websocket-client pyyaml scipyFor the GUI waterfall display (optional):
conda install -y -c conda-forge pyqtgraph pyside6git clone https://github.com/km6am/morse-code-recorder.git
cd morse-code-recorder
python -m pytest tests/ -vAll tests should pass (one Windows file-locking error in cleanup is expected). No SDR hardware is needed for tests.
Connect the RSPdx via USB. Make sure SDRuno or other SDR software is closed (only one application can use the SDR at a time).
From the project directory, activate radioconda and run:
activate.bat
python -m cw_recorder --band 20mThis monitors the 20m non-voice segment (14.000-14.150 MHz) and saves CW recordings to ./recordings/.
For an RTL-SDR, set device_type in config.yaml:
sdr:
device_type: "rtlsdr"
band: "20m"
sample_rate_hz: 250000
gain: 40Press Ctrl+C. In-progress recordings are finalized and saved on shutdown.
python -m cw_recorder [OPTIONS]
Options:
--config PATH Config file (default: config.yaml)
--band BAND Ham band: 160m 80m 60m 40m 30m 20m 17m 15m 12m 10m
--display Terminal status display (signal list, peaks, stats)
--gui GUI waterfall display (requires pyqtgraph + PySide6)
# Monitor 20m with terminal display
python -m cw_recorder --band 20m --display
# Monitor 40m with GUI waterfall
python -m cw_recorder --band 40m --gui
# Use a custom config file
python -m cw_recorder --config my_config.yaml
# Headless (log output only, good for unattended operation)
python -m cw_recorder --band 20mHeadless (default) - log output only. Best for unattended recording.
Terminal (--display) - fixed-height text display showing:
- Active and silent signals with frequency, SNR, WPM, duration
- Spectrum peaks with CW markers
- Recording stats (saved/rejected/short counts)
GUI (--gui) - pyqtgraph window with:
- Live spectrum plot with noise floor and signal markers
- Scrolling waterfall with configurable colormap
- Scan region overlay (shaded exclusion zones)
- Signal table with real-time WPM updates
Edit config.yaml or use --band to set the band. The --band flag overrides center frequency and scan limits from the config file.
sdr:
device_type: "rspdx" # "rspdx", "rtlsdr", "hackrf", "lime", "pluto", "usrp", "soapy"
band: "20m" # Sets center freq + scan limits automatically
sample_rate_hz: 200000 # 200 kHz bandwidth
gain: 30 # Single gain value (for non-RSPdx SDRs)
antenna_port: "Antenna B" # RSPdx antenna port (A, B, or C)
if_gain_reduction_db: 40 # IF gain reduction (0-59)
rf_gain_reduction_db: 0 # RF gain reduction
agc_enabled: false # Manual gain recommended for CW
biasT: false # Bias-T power for active antennas
detection:
snr_threshold_db: 6 # dB above noise floor to trigger detection
max_signal_bandwidth_hz: 500 # Reject signals wider than this
silence_timeout_s: 15 # End recording after this much silence
min_recording_duration_s: 5 # Discard recordings shorter than this
min_wpm: 5 # WPM range for validation
max_wpm: 50
reject_ft8: true # Skip FT8 frequency windows (default: on)
# Override scan limits (normally set by band):
# scan_freq_min_hz: 14000000
# scan_freq_max_hz: 14150000
recording:
output_dir: "./recordings"
sample_rate_hz: 8000 # Audio sample rate
sidetone_hz: 700 # BFO sidetone frequency
max_disk_usage_mb: 1024 # Auto-prune oldest when exceeded| Band | Center (MHz) | Scan Range (kHz) | Notes |
|---|---|---|---|
| 160m | 1.900 | 1800-2000 | Nighttime, needs good antenna |
| 80m | 3.550 | 3500-3600 | Nighttime |
| 60m | 5.359 | 5332-5405 | Channelized, limited |
| 40m | 7.063 | 7000-7125 | Day and night |
| 30m | 10.125 | 10100-10150 | CW/digital only, 24hr |
| 20m | 14.075 | 14000-14150 | Best daytime band |
| 17m | 18.090 | 18068-18110 | Daytime |
| 15m | 21.075 | 21000-21200 | Daytime, solar cycle dependent |
| 12m | 24.920 | 24890-24930 | Daytime, narrow |
| 10m | 28.100 | 28000-28300 | Daytime, solar cycle dependent |
Recordings are saved to ./recordings/ organized by date:
recordings/
2026-04-11/
0001_14043.6kHz_2026-04-11T19-29-41Z.mp3
0001_14043.6kHz_2026-04-11T19-29-41Z.json
0002_14028.4kHz_2026-04-11T19-30-53Z.mp3
0002_14028.4kHz_2026-04-11T19-30-53Z.json
File numbers are sequential with no gaps and persist across sessions.
Each MP3 has a JSON sidecar with recording details:
{
"signal_id": "a1b2c3d4e5f6",
"freq_hz": 14043603.515625,
"start_time_utc": "2026-04-11T19:29:41+00:00",
"duration_s": 45.0,
"active_duration_s": 32.5,
"duty_cycle": 0.72,
"snr_db": 15.0,
"est_wpm_range": [32.0, 32.0],
"sample_rate_hz": 8000,
"sidetone_hz": 700,
"timing": {
"char_wpm": 32.0,
"effective_wpm": 32.0,
"dit_ms": 37.5,
"dah_ms": 118.5,
"ratio": 3.16,
"confidence": 0.97,
"is_farnsworth": false,
"n_dits": 27,
"n_dahs": 14,
"n_elements": 41
}
}# Find high-SNR recordings around 20 WPM
python -m tools.find_recordings --wpm-min 18 --wpm-max 22 --snr-min 15
# Long recordings sorted by duration
python -m tools.find_recordings --dur-min 60 --sort dur
# Play matching recordings interactively
python -m tools.find_recordings --snr-min 20 --playpython tools/label_recordings.pyPlays recordings and prompts for CW/NOT_CW labels. Results saved to tools/labels.json.
# Record 10 minutes of spectrum snapshots for offline analysis
python -m tools.capture_iq --config config.yaml --duration 600 --output capture.npzpython tools/test_kiwi_pipeline.py --host kiwisdr.briata.org --freq 7025 --bandwidth 40 --duration 120Find public KiwiSDRs at kiwisdr.com/public.
RSPdx SDR (GNU Radio flowgraph)
|-- Path 1: FFT spectrum --> Scanner (find narrowband peaks)
|-- Path 2: IQ ring buffer --> NarrowbandExtractor (per-signal, phase-coherent)
|
CW Validator (bimodal + timing + ratio, 3-of-3)
|
Signal Tracker (active --> silent --> ended)
|
Recorder (BFO demod, smooth AGC, streaming MP3)
|
Audio Validator (tone SNR + on/off contrast)
|
Morse Timing (K-means dit/dah, Farnsworth)
|
Output: MP3 + JSON sidecar
- Scanner - FFT spectrum analysis finds narrowband peaks above the noise floor
- Validator - 1-second IQ snapshot tested for bimodal amplitude, Morse timing, and dit/dah ratio (all 3 must pass to start tracking)
- Tracker - maintains signal lifecycle; once validated, stays active as long as scanner sees a peak (no re-validation needed each cycle)
- Recorder - streams phase-coherent narrowband IQ through multi-stage IIR decimation, BFO demodulation, and smooth AGC to MP3
- Audio Validator - post-recording analysis rejects false positives based on tone SNR and on/off contrast
- Morse Timing - K-means clustering of accumulated dit/dah durations produces refined WPM estimate
- IQ ring buffer with streaming reads -
get_new_iq()returns contiguous samples since last call using a monotonic cursor. No gaps, no overlap, no re-reading. - Per-signal NarrowbandExtractor - each tracked signal gets its own extractor maintaining frequency shift phase and IIR filter state across chunks. Eliminates clicks at chunk boundaries.
- Multi-stage IIR decimation - 200 kHz to 500 Hz in 3 stages (10x, 10x, 4x) using 4th-order Butterworth filters. 9ms per signal vs 100ms with FIR.
- Scanner-based tracking - validation is required once to start tracking. After that, any scanner peak within 200 Hz keeps the signal active. Natural CW pauses don't kill recordings.
- Working directory - recordings write to
.working/during capture. Only validated recordings move to the output folder with gap-free numbering.
If the SDR is still held by a previous session, the recorder will retry once after a 5-second countdown. Close SDRuno or any other SDR software before starting.
rspdx :warning: overload detected means the signal level is too high. Increase if_gain_reduction_db in the config (e.g., 40 to 50).
- Check antenna connection and port setting (
Antenna A,B, orC) - Try a different band - 20m is best during daytime, 40m/80m at night
- Lower
snr_threshold_db(default 6 dB) - Verify frequency with SDRuno to confirm the SDR is receiving
Copyright (C) 2026 Tony (KM6AM)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License v3. See LICENSE for details.
The one PermissionError in test_start_recording_creates_file is a known Windows file-locking issue during temp directory cleanup. All other tests should pass.