Motivation
Today, paddles connect either directly to the FlexRadio (radio handles iambic timing, sidetone is hardware) or via a WinKeyer hardware bridge + WkFlex. AetherSDR has no path for connecting a paddle directly to the PC and using the radio as a "dumb keyer".
For ops who want fully-local keying feel — sub-5 ms sidetone latency, paddle attached to the laptop running AetherSDR, no extra hardware — we need a software iambic keyer. The pieces are now in place after PR #2075 (PortAudio sidetone backend, sub-5 ms callback path) and PR #1969 (CwSidetoneGenerator with atomic key-down gate). What's missing is paddle input + iambic state machine + UDP keying out to the radio.
Goal
Enable: connect a paddle to a serial port (most ham paddles ship with serial cables wired to RTS/CTS), AetherSDR generates Morse element timing locally, drives local sidetone instantly via the existing CW sidetone path, and emits precisely-timed key-up/key-down events to the radio over the netcw UDP stream.
Architecture
[ Paddle ] → [ Serial port DTR/CTS lines ] → [ PaddleReader ]
│
│ (worker thread,
│ high-res clock)
▼
[ IambicKeyer state machine ]
│
┌─────────────────┼──────────────────┐
▼ ▼
[ CwSidetoneGenerator::setKeyDown ] [ netcw UDP packets to radio ]
(atomic, lock-free, sub-5ms audible) (via RadioModel, Qt::QueuedConnection)
Two output destinations from the state machine, separately:
- Sidetone: write directly to the existing
std::atomic<bool> in CwSidetoneGenerator — no Qt signal, no event-loop hop.
- Radio: post the keying packets to
RadioModel's thread via QMetaObject::invokeMethod so the network I/O stays on its own thread.
Existing groundwork
| Component |
Status |
Role in this work |
CwSidetoneGenerator |
Exists (PR #1969) |
Atomic key-down gate; lock-free process() already callback-safe |
CwSidetonePortAudioSink |
Exists (PR #2075) |
Sub-5 ms audio backend |
CwxLocalKeyer |
Exists |
Text-driven CW (CWX) — DIFFERENT path; will coexist |
| netcw UDP transport |
Exists |
Already used by CwxLocalKeyer for outgoing keying |
Qt6::SerialPort |
Optional dep, gated by HAVE_SERIALPORT |
Used for FlexControl, PTT footswitch — paddle input adds another consumer |
Components
1. PaddleReader (new — src/core/PaddleReader.{h,cpp})
- Reads paddle state from a configured input source.
- v1:
Qt6::SerialPort polling DTR/CTS or DSR/RTS lines (ham paddle wiring varies — make polarity configurable).
- Runs on a dedicated worker thread named
paddle-reader. Polls at 1 kHz minimum.
- Emits
paddleEdge(side, pressed) where side is Dit or Dah.
2. IambicKeyer (new — src/core/IambicKeyer.{h,cpp})
State machine. Modes:
- Iambic A — alternate dit/dah while both paddles squeezed; stop at element boundary when released.
- Iambic B — like A, but adds one extra element if both paddles are released during the second-to-last element. Default for most modern ops.
- Ultimatic — output the last paddle pressed.
- Bug — dah is hand-keyed (no auto-repeat); dit auto-generates.
- Straight key — single paddle = key-down, no auto-timing.
Parameters:
- WPM (5–60)
- Weighting (0.5–1.5; default 1.0 — adjusts dit duration relative to standard)
- Dit/dah ratio (2.5–4.0; default 3.0)
- Inter-character gap multiplier (default 3.0 dits)
Element timing reference (PARIS = 50 elements per word):
dit_ms = 1200 * weighting / WPM
dah_ms = ratio * dit_ms
inter_element_gap_ms = dit_ms
inter_char_gap_ms = char_gap_mult * dit_ms
Implementation notes:
- Runs on the same worker thread as
PaddleReader to avoid the cross-thread hop on every paddle edge.
- Element timing via
std::this_thread::sleep_until against std::chrono::steady_clock — not QTimer. QTimer jitter is too high for CW timing.
- Dit memory and dah memory flags for iambic mode B.
- Output: pair of
std::function callbacks set at construction:
onKeyDownChange(bool down) — flips sidetone gate AND posts radio command
- Both are called inline from the state machine thread; the radio-side path uses
QMetaObject::invokeMethod internally to hop to RadioModel's thread.
3. Radio integration
RadioModel already exposes the netcw transmit path used by CwxLocalKeyer. Wire IambicKeyer's output through the same path.
The radio's cw key immediate command is broken on FW v1.4.0.0 (returns 0x50001000 — see CLAUDE.md), so netcw UDP is mandatory; no fallback.
4. Settings
New AppSettings keys:
LocalIambicEnabled — master toggle
LocalIambicSerialPort — /dev/ttyUSB0, COM3, etc.
LocalIambicDitLine — DTR / RTS / DSR / CTS
LocalIambicDahLine — same
LocalIambicMode — A / B / Ultimatic / Bug / Straight
LocalIambicWPM — 5..60, default 20
LocalIambicWeighting — float, default 1.0
LocalIambicRatio — float, default 3.0
LocalIambicSwapPaddles — bool, default false
5. UI surface
Two options:
- Add to PhoneCwApplet — fits the CW operating context. Needs an "Iambic" group: enable, port, mode, WPM, weighting, ratio, swap.
- New IambicApplet — dedicated tile. More discoverable but adds tile clutter.
Recommend: settings live in PhoneCwApplet (existing CW context); a small status indicator (active mode, current WPM, paddle press LEDs) at the top of the applet.
File changes
| File |
Action |
Lines (est.) |
src/core/PaddleReader.{h,cpp} |
NEW |
~150 |
src/core/IambicKeyer.{h,cpp} |
NEW |
~300 |
tests/iambic_keyer_test.cpp |
NEW |
~250 |
src/core/RadioModel.{h,cpp} |
MOD |
~30 (expose netcw key methods to IambicKeyer) |
src/core/AudioEngine.{h,cpp} |
MOD |
~10 (no change — we just call m_cwSidetone->setKeyDown() from the keyer) |
src/gui/PhoneCwApplet.{h,cpp} |
MOD |
~150 (settings UI + status display) |
CMakeLists.txt |
MOD |
~10 (new test target) |
CLAUDE.md |
MOD |
~30 (note for AI agents) |
Phasing
Phase 1 — MVP (~3–4 focused days)
IambicKeyer with Iambic A and B only, WPM control, no weighting/ratio knobs
PaddleReader with Qt6::SerialPort DTR/CTS input only
- Wire to existing netcw + sidetone paths
- Settings + minimal UI in PhoneCwApplet
- Unit tests: state machine timing, mode A/B transitions, dot memory
Acceptance: plug a paddle into a USB-serial adapter, configure port + mode, paddle keys the radio with audible local sidetone at <5 ms.
Phase 2 — Polish (~2–3 days)
- Ultimatic, Bug, Straight key modes
- Weighting and ratio knobs
- Dit/dah memory diagnostics
- UI: paddle press LED indicators, mode-cycle hotkey
Phase 3 — Extended hardware (~2–3 days, optional)
- USB HID paddle support (newer Begali, Vibroplex etc.)
- MIDI footswitch hot-key for mode switch / tune
- Cross-source key-state aggregation: paddle + CWX + MIDI all feed the same sidetone gate without conflicts
Risks
| Risk |
Likelihood |
Mitigation |
| Element timing jitter from OS scheduler |
Medium |
Worker thread with SCHED_FIFO / THREAD_PRIORITY_TIME_CRITICAL where available; steady_clock not QTimer |
| netcw network jitter affects on-air timing |
Medium |
Outgoing packets already include timing offsets (the radio accepts a schedule, not real-time toggles) — verify with on-air recording |
| Paddle wiring polarity varies |
High |
Configurable line+polarity per paddle; expose in UI |
| Mode A vs B subtlety produces operator complaints |
High |
Implement against documented WinKeyer reference; let users A/B compare against their existing WinKeyer setup; unit tests for both modes |
| CWX coexistence — paddle press during CWX send |
Medium |
Decision needed: paddle interrupts CWX (default), or paddle is ignored while CWX is active (toggle setting) |
| Serial port already claimed by another consumer (FlexControl, PTT) |
Low |
Same QSerialPort instance can only have one owner; document the constraint, error clearly if port busy |
Test strategy
- Unit tests —
IambicKeyer state machine: element timing within 1 ms tolerance, mode A vs B transitions on both-paddles release, dit/dah memory, weighting math.
- Loopback test — synthetic paddle edge events; capture netcw packets; verify element timing within 5 ms.
- Audio test — record sidetone output, FFT it to verify dit/dah duration matches WPM, check inter-element gap stability.
- Real-radio test — connect to FLEX-8400, send "PARIS" at 20 WPM, record on second receiver, verify element count + spacing matches reference.
- Side-by-side test — same paddle through AetherSDR vs through a real WinKeyer + WkFlex; experienced ops report subjective parity.
References
Motivation
Today, paddles connect either directly to the FlexRadio (radio handles iambic timing, sidetone is hardware) or via a WinKeyer hardware bridge + WkFlex. AetherSDR has no path for connecting a paddle directly to the PC and using the radio as a "dumb keyer".
For ops who want fully-local keying feel — sub-5 ms sidetone latency, paddle attached to the laptop running AetherSDR, no extra hardware — we need a software iambic keyer. The pieces are now in place after PR #2075 (PortAudio sidetone backend, sub-5 ms callback path) and PR #1969 (
CwSidetoneGeneratorwith atomic key-down gate). What's missing is paddle input + iambic state machine + UDP keying out to the radio.Goal
Enable: connect a paddle to a serial port (most ham paddles ship with serial cables wired to RTS/CTS), AetherSDR generates Morse element timing locally, drives local sidetone instantly via the existing CW sidetone path, and emits precisely-timed key-up/key-down events to the radio over the netcw UDP stream.
Architecture
Two output destinations from the state machine, separately:
std::atomic<bool>inCwSidetoneGenerator— no Qt signal, no event-loop hop.RadioModel's thread viaQMetaObject::invokeMethodso the network I/O stays on its own thread.Existing groundwork
CwSidetoneGeneratorprocess()already callback-safeCwSidetonePortAudioSinkCwxLocalKeyerCwxLocalKeyerfor outgoing keyingQt6::SerialPortHAVE_SERIALPORTComponents
1.
PaddleReader(new —src/core/PaddleReader.{h,cpp})Qt6::SerialPortpolling DTR/CTS or DSR/RTS lines (ham paddle wiring varies — make polarity configurable).paddle-reader. Polls at 1 kHz minimum.paddleEdge(side, pressed)wheresideisDitorDah.2.
IambicKeyer(new —src/core/IambicKeyer.{h,cpp})State machine. Modes:
Parameters:
Element timing reference (PARIS = 50 elements per word):
Implementation notes:
PaddleReaderto avoid the cross-thread hop on every paddle edge.std::this_thread::sleep_untilagainststd::chrono::steady_clock— notQTimer. QTimer jitter is too high for CW timing.std::functioncallbacks set at construction:onKeyDownChange(bool down)— flips sidetone gate AND posts radio commandQMetaObject::invokeMethodinternally to hop toRadioModel's thread.3. Radio integration
RadioModelalready exposes the netcw transmit path used byCwxLocalKeyer. WireIambicKeyer's output through the same path.The radio's
cw key immediatecommand is broken on FW v1.4.0.0 (returns 0x50001000 — see CLAUDE.md), so netcw UDP is mandatory; no fallback.4. Settings
New
AppSettingskeys:LocalIambicEnabled— master toggleLocalIambicSerialPort—/dev/ttyUSB0,COM3, etc.LocalIambicDitLine—DTR/RTS/DSR/CTSLocalIambicDahLine— sameLocalIambicMode—A/B/Ultimatic/Bug/StraightLocalIambicWPM— 5..60, default 20LocalIambicWeighting— float, default 1.0LocalIambicRatio— float, default 3.0LocalIambicSwapPaddles— bool, default false5. UI surface
Two options:
Recommend: settings live in PhoneCwApplet (existing CW context); a small status indicator (active mode, current WPM, paddle press LEDs) at the top of the applet.
File changes
src/core/PaddleReader.{h,cpp}src/core/IambicKeyer.{h,cpp}tests/iambic_keyer_test.cppsrc/core/RadioModel.{h,cpp}src/core/AudioEngine.{h,cpp}m_cwSidetone->setKeyDown()from the keyer)src/gui/PhoneCwApplet.{h,cpp}CMakeLists.txtCLAUDE.mdPhasing
Phase 1 — MVP (~3–4 focused days)
IambicKeyerwith Iambic A and B only, WPM control, no weighting/ratio knobsPaddleReaderwithQt6::SerialPortDTR/CTS input onlyAcceptance: plug a paddle into a USB-serial adapter, configure port + mode, paddle keys the radio with audible local sidetone at <5 ms.
Phase 2 — Polish (~2–3 days)
Phase 3 — Extended hardware (~2–3 days, optional)
Risks
SCHED_FIFO/THREAD_PRIORITY_TIME_CRITICALwhere available;steady_clocknotQTimerTest strategy
IambicKeyerstate machine: element timing within 1 ms tolerance, mode A vs B transitions on both-paddles release, dit/dah memory, weighting math.References
CwSidetoneGenerator(PR Local CW sidetone with dedicated low-latency sink and CWX support #1969) — atomic key-down gateCwSidetonePortAudioSink(PR CW sidetone: PortAudio backend for sub-5ms latency on Linux/macOS #2075) — sub-5 ms audio pathCwxLocalKeyer— existing text-mode CW path; reference for netcw transmit patterncw key immediateis broken on v1.4.0.0; netcw UDP is the only working path