Skip to content

Local CW sidetone with dedicated low-latency sink and CWX support#1969

Merged
ten9876 merged 3 commits intomainfrom
auto/Local-CW-sidetone-with-dedicated-low-latency-sink-
Apr 25, 2026
Merged

Local CW sidetone with dedicated low-latency sink and CWX support#1969
ten9876 merged 3 commits intomainfrom
auto/Local-CW-sidetone-with-dedicated-low-latency-sink-

Conversation

@ten9876
Copy link
Copy Markdown
Owner

@ten9876 ten9876 commented Apr 25, 2026

Adds a client-side CW sidetone generator that fires within ~25 ms of a
key event, independent of the radio's DAX-fed sidetone (which has
30–100 ms of round-trip latency, brutal at 25+ WPM).

Architecture:

  • src/core/CwSidetoneGenerator — DSP core. State machine
    (idle/ramp-up/sustain/ramp-down), atomic params for live UI updates,
    raised-cosine envelope at 5 ms default for click-free attack/release,
    continuous-phase sine. 10 unit tests covering enable/disable, pitch
    via zero-crossings, volume scaling, envelope shape, ramp transitions,
    and reset.

  • AudioEngine owns a dedicated QAudioSink for sidetone, separate from
    the RX sink so RX keeps its 200 ms jitter cushion while sidetone
    runs on a 50 ms buffer fed by a 2 ms PreciseTimer. The dedicated
    sink + push mode were chosen after a pull-mode attempt flapped
    Idle/Active in 85 ms cycles on Linux/Pulse, producing audible chop.
    Diagnostic logs from that flapping run pinpointed the root cause —
    removed before commit so steady-state operation is logging-free.

  • RadioModel emits cwKeyDownChanged on every transition through
    sendCwKey/sendCwPaddle. Every existing key source funnels through
    those: serial CTS/DSR (FlexControl), MIDI Gate (MidiControlManager),
    TCI straight-key, CWX, HID encoder. One signal hooks all of them.

  • src/core/CwxLocalKeyer — Morse keyer that translates CWX text +
    WPM into a sequence of dit/dah/gap timing events. CwxModel emits
    transmissionRequested(text, wpm) from send/sendChar/sendMacro and
    transmissionCancelled from erase/clearBuffer; the keyer drives the
    same sidetone generator so CWX transmissions get audible feedback
    matching what the radio is sending. Both sides run at the same
    WPM so they stay in sync within ±1 element on typical hardware.

  • PhoneCwApplet UI gains a "Local STn" toggle, volume slider, "Follow"
    toggle (radio's cw_pitch), and manual pitch slider (disabled when
    follow is on). Persisted via AppSettings keys CwLocalSidetoneEnabled,
    CwLocalSidetoneVolume, CwLocalSidetonePitchFollow, CwLocalSidetonePitchHz.

  • TransmitModel::phoneStateChanged drives pitch-follow updates so the
    sidetone tracks any radio-side cw_pitch change.

Co-Authored-By: Claude Opus 4.7 (1M context) [email protected]

Adds a client-side CW sidetone generator that fires within ~25 ms of a
key event, independent of the radio's DAX-fed sidetone (which has
30–100 ms of round-trip latency, brutal at 25+ WPM).

Architecture:

* src/core/CwSidetoneGenerator — DSP core.  State machine
  (idle/ramp-up/sustain/ramp-down), atomic params for live UI updates,
  raised-cosine envelope at 5 ms default for click-free attack/release,
  continuous-phase sine.  10 unit tests covering enable/disable, pitch
  via zero-crossings, volume scaling, envelope shape, ramp transitions,
  and reset.

* AudioEngine owns a dedicated QAudioSink for sidetone, separate from
  the RX sink so RX keeps its 200 ms jitter cushion while sidetone
  runs on a 50 ms buffer fed by a 2 ms PreciseTimer.  The dedicated
  sink + push mode were chosen after a pull-mode attempt flapped
  Idle/Active in 85 ms cycles on Linux/Pulse, producing audible chop.
  Diagnostic logs from that flapping run pinpointed the root cause —
  removed before commit so steady-state operation is logging-free.

* RadioModel emits cwKeyDownChanged on every transition through
  sendCwKey/sendCwPaddle.  Every existing key source funnels through
  those: serial CTS/DSR (FlexControl), MIDI Gate (MidiControlManager),
  TCI straight-key, CWX, HID encoder.  One signal hooks all of them.

* src/core/CwxLocalKeyer — Morse keyer that translates CWX text +
  WPM into a sequence of dit/dah/gap timing events.  CwxModel emits
  transmissionRequested(text, wpm) from send/sendChar/sendMacro and
  transmissionCancelled from erase/clearBuffer; the keyer drives the
  same sidetone generator so CWX transmissions get audible feedback
  matching what the radio is sending.  Both sides run at the same
  WPM so they stay in sync within ±1 element on typical hardware.

* PhoneCwApplet UI gains a "Local STn" toggle, volume slider, "Follow"
  toggle (radio's cw_pitch), and manual pitch slider (disabled when
  follow is on).  Persisted via AppSettings keys CwLocalSidetoneEnabled,
  CwLocalSidetoneVolume, CwLocalSidetonePitchFollow, CwLocalSidetonePitchHz.

* TransmitModel::phoneStateChanged drives pitch-follow updates so the
  sidetone tracks any radio-side cw_pitch change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ten9876 ten9876 enabled auto-merge (squash) April 25, 2026 18:15
Three protocol-correctness fixes on the netCW UDP keying path, mirrored
from FlexLib reference (Radio.cs:8890-8965, NetCWStream.cs).  None of
these solved the underlying issue that the radio doesn't transition to
TRANSMIT on netCW commands, but they bring the wire format closer to
what FlexLib actually ships and clear three subtle deviations.

1. sendCwKey/sendCwPaddle now bracket each `cw key` with a `cw ptt 1`
   on press and `cw ptt 0` on release, matching FlexLib's CWPTT+CWKey
   pair.  Without explicit PTT the radio queues key events with
   break_in=0 instead of transmitting.

2. buildNetCwPacket no longer pads the payload to a 4-byte boundary.
   FlexLib's AddTXData sends `tx_data.Length` payload bytes exactly
   (so a 57-byte command yields an 85-byte datagram, not 88).  The
   radio uses datagram length to delimit the command string; trailing
   zero bytes can confuse the parser.

3. sendNetCwCommand now builds four independent packets for the
   redundant 0/5/10/15 ms sends so each datagram has a unique VITA-49
   packet_count.  FlexLib increments packet_count after every
   ToBytesTX() call, so four redundant copies arrive with counts
   N, N+1, N+2, N+3.  Reusing one buffer for all four makes the
   stream layer drop them as duplicates.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
M_PI is a POSIX extension and not defined by MSVC <cmath> without
_USE_MATH_DEFINES.  Use an explicit constexpr kPi instead so the
sidetone generator builds on Windows CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant