Skip to content

fix(cwx): run local sidetone keyer on a steady_clock worker thread#3644

Merged
ten9876 merged 2 commits into
aethersdr:mainfrom
nigelfenton:fix/cwx-sidetone-keyer-offthread
Jun 19, 2026
Merged

fix(cwx): run local sidetone keyer on a steady_clock worker thread#3644
ten9876 merged 2 commits into
aethersdr:mainfrom
nigelfenton:fix/cwx-sidetone-keyer-offthread

Conversation

@nigelfenton

@nigelfenton nigelfenton commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #3623 — the CWX local sidetone drops/clips individual CW elements under panadapter/VITA-49 load on Windows.

Root cause

CwxLocalKeyer scheduled every element edge with a QTimer parented to MainWindow, so the timer ran on the GUI event loop and keyStateChanged was delivered through a GUI-thread slot. Under panadapter paint + VITA-49 burst handling the loop coalesces both, the key gate lands a block or two late, and an element gets gapped/clipped. #3202's drift-correction fixes cumulative slip (the message stays in sync) but not this per-edge jitter, and reasons specifically about macOS — this is the residual Windows case.

Fix

Re-architect CwxLocalKeyer to mirror IambicKeyer, which already abandoned QTimer for exactly this reason ("QTimer's jitter is too high for CW"):

  • Run the element schedule on a dedicated worker thread.
  • Time each edge to an absolute std::chrono::steady_clock deadline waited on an interruptible condition_variable — no QTimer, no event loop.
  • Drive the sidetone gate via a lock-free onKeyDownChange callback; CwSidetoneGenerator::setKeyDown is std::atomic, the same gate path the iambic keyer uses.

encode() and the drift-correction logic are unchanged, so message timing and the live-mode CharGap behaviour (#2473/#2754) are preserved. MainWindow now owns the keyer as a unique_ptr and stops it (joining the worker) before AudioEngine is torn down, since the gate callback touches m_audio->cwSidetone().

This is the approach recommended in the issue triage.

Testing

  • cwx_local_keyer_drift_test ([cw] Add CwxLocalKeyer drift-correction regression test #3271) retargeted to the new clock seam (worker-disabled synchronous subclass + callback) — passes; all drift-correction assertions (absolute edge targets, slip-shortened waits, sustained-overload clamping, queued-macro epoch continuity, stop/drain resets) still hold, so the scheduling math is unchanged.
  • Clean build (Windows MSVC, Qt 6.10.3).
  • Ran the app against a synthetic FlexRadio source under a heavy spectrum load and sent long CWX macros: CW sends cleanly at both 20 and 40 wpm — the element drops/stutter are gone, including at 40 wpm where the tighter element timing is the more demanding case. Clean startup / connect / shutdown (worker joins without hanging).

Known residual

Verified clean at both 20 and 40 wpm under heavy synthetic load. A single faint, low-level click heard at 20 wpm did not recur and isn't speed-correlated (40 wpm — tighter timing — is completely clean), consistent with an incidental audio under-run under the deliberately brutal synthetic load rather than the keyer. CwSidetoneGenerator is unchanged by this PR and its raised-cosine envelope ramps cleanly across block-quantized edges. The "fullest fix" floated in the issue (advancing the gate by sample count inside CwSidetoneGenerator::process()) remains available as a follow-up if sub-block edge precision is ever wanted.

🤖 Generated with Claude Code

CwxLocalKeyer scheduled each CW element edge with a QTimer parented to
MainWindow, so the timer fired on the GUI event loop and keyStateChanged
was delivered through a GUI-thread slot. Under panadapter paint / VITA-49
burst load the loop coalesces both, the key gate lands a block or two late,
and individual CW elements drop or clip -- audible sidetone stutter on
Windows that aethersdr#3202's drift-correction (cumulative slip) does not cover.

Re-architect the keyer to mirror IambicKeyer, which abandoned QTimer for the
same reason ("QTimer's jitter is too high for CW"): run the element schedule
on a dedicated worker thread, time each edge to an absolute steady_clock
deadline on an interruptible condition_variable, and drive the sidetone gate
through a lock-free onKeyDownChange callback (CwSidetoneGenerator::setKeyDown
is std::atomic). The encode and drift-correction logic is unchanged, so
message timing and the live-mode CharGap behaviour (aethersdr#2473/aethersdr#2754) are preserved.

MainWindow now owns the keyer as a unique_ptr and stops it (joining the
worker) before AudioEngine is torn down, since the gate callback touches
m_audio->cwSidetone(). The aethersdr#3271 drift regression test is retargeted to the
new clock seam and still passes.

Fixes aethersdr#3623.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nigelfenton nigelfenton requested review from a team as code owners June 17, 2026 23:16

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, @nigelfenton — this is a careful, well-reasoned port. I traced the new threading model against IambicKeyer (which already does exactly this) and the key correctness points all hold up. CI is green across build/macOS/Windows/CodeQL.

What I verified

  • Shutdown ordering is correct. MainWindow::~MainWindow() calls m_cwxLocalKeyer.reset() (line 1951) before the AudioEngine teardown at ~1977–1996, so the worker is joined before m_audio is freed — no key edge can fire into a dead audio object. This is the crux of the change and it's handled properly.
  • Gate path is genuinely lock-free. CwSidetoneGenerator::setKeyDown is a std::atomic store (CwSidetoneGenerator.cpp:71), so calling onKeyDownChange directly from the worker thread is safe — matching the iambic keyer's gate exactly.
  • Thread-safety partition is sound. m_queue is the only cross-thread state and is consistently guarded by m_mu; the schedule state (m_elements, m_running, m_epoch, m_currentlyDown) is touched only by whichever thread runs scheduleNext(). No lost-wakeup: start() enqueues under the lock and the worker re-checks the predicate under the same lock. The stale-abort-on-idle case is correctly cleared at the top of each send run.
  • Test seam preserved. Retargeting the drift test to the spawnWorker=false synchronous subclass + onKeyDownChange callback keeps the #3271 coverage deterministic without a real thread, and the cumulative/late-tick assertions are unchanged. The pthread link guard for the test on UNIX is the right call.

Minor (non-blocking)

  • isIdle() now reads m_elements/m_running — worker-owned state — so it would be a data race if ever called from the GUI thread. It currently has no callers, so this is benign today; worth a one-line caveat on the declaration (or routing it through m_mu) if it's ever promoted to a real public query, since the old QObject version was single-threaded and this hazard is new.
  • The worker's onKeyDownChange lambda reads m_audio without synchronization, same theoretical lifetime/TOCTOU pattern as the existing iambic gate — acceptable since it mirrors the established code and the shutdown join guards the real teardown case.

Nothing here is a blocker. Clean change that matches repo conventions (RAII unique_ptr ownership, the steady_clock worker pattern from IambicKeyer). Nice work isolating this from the #3202 cumulative-drift logic and the live-mode CharGap behaviour.


🤖 aethersdr-agent · cost: $7.8101 · model: claude-opus-4-8

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@ten9876 ten9876 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — a correct, well-executed real-time concurrency fix mirroring the proven IambicKeyer pattern.

Verified:

  • Concurrency: worker parks on cv.wait(pred); each edge sleeps on an interruptible wait_until(absolute steady_clock deadline, [abort||shutdown]) — drift-corrected, no QTimer coalescing. Queue dequeued under m_mu; gate callback fires outside the lock and is lock-free (atomic setKeyDown); both waits predicated (spurious-wakeup safe).
  • Lifecycle: unique_ptr ownership; reset() joins the worker before AudioEngine teardown (commented), and the gate callback null-guards m_audio/cwSidetone() regardless — no stuck tone, UAF, or shutdown hang.
  • Abort/stop: clears queue + sets m_abort + notifies; worker wakes, keys up, resets to idle. Latched abort is cleared at the next send run; idle wait doesn't gate on it — no stuck state.
  • Drift-correction preserved: encode() + scheduling math unchanged; the #3271 drift test retargeted to the synchronous spawnWorker=false seam still passes every assertion.

Live-validated at 20 and 40 wpm under heavy synthetic load (drops gone), clean startup/connect/shutdown. The documented faint non-speed-correlated click is plausibly an incidental under-run (keyer unchanged), with the sub-block-precision fix noted as a future follow-up. CI green across all six. Nice work @nigelfenton.

@ten9876 ten9876 merged commit 9e96249 into aethersdr:main Jun 19, 2026
6 checks passed
@nigelfenton nigelfenton deleted the fix/cwx-sidetone-keyer-offthread branch June 19, 2026 13:00
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.

CWX local sidetone drops individual elements under panadapter/VITA-49 load (Windows, v26.6.3)

2 participants