Skip to content

Local iambic keying: paddle → PC → radio with sub-5ms sidetone #2079

@ten9876

Description

@ten9876

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_clocknot 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.
  • LocalIambicDitLineDTR / RTS / DSR / CTS
  • LocalIambicDahLine — same
  • LocalIambicModeA / 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

  1. Unit testsIambicKeyer state machine: element timing within 1 ms tolerance, mode A vs B transitions on both-paddles release, dit/dah memory, weighting math.
  2. Loopback test — synthetic paddle edge events; capture netcw packets; verify element timing within 5 ms.
  3. Audio test — record sidetone output, FFT it to verify dit/dah duration matches WPM, check inter-element gap stability.
  4. Real-radio test — connect to FLEX-8400, send "PARIS" at 20 WPM, record on second receiver, verify element count + spacing matches reference.
  5. Side-by-side test — same paddle through AetherSDR vs through a real WinKeyer + WkFlex; experienced ops report subjective parity.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementImprovement to existing featureno-claudeReserved for human implementation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions