fix(cwx): run local sidetone keyer on a steady_clock worker thread#3644
Conversation
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>
There was a problem hiding this comment.
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()callsm_cwxLocalKeyer.reset()(line 1951) before theAudioEngineteardown at ~1977–1996, so the worker is joined beforem_audiois 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::setKeyDownis astd::atomicstore(CwSidetoneGenerator.cpp:71), so callingonKeyDownChangedirectly from the worker thread is safe — matching the iambic keyer's gate exactly. - Thread-safety partition is sound.
m_queueis the only cross-thread state and is consistently guarded bym_mu; the schedule state (m_elements,m_running,m_epoch,m_currentlyDown) is touched only by whichever thread runsscheduleNext(). 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=falsesynchronous subclass +onKeyDownChangecallback keeps the #3271 coverage deterministic without a real thread, and the cumulative/late-tick assertions are unchanged. Thepthreadlink guard for the test on UNIX is the right call.
Minor (non-blocking)
isIdle()now readsm_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 throughm_mu) if it's ever promoted to a real public query, since the oldQObjectversion was single-threaded and this hazard is new.- The worker's
onKeyDownChangelambda readsm_audiowithout 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
left a comment
There was a problem hiding this comment.
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 interruptiblewait_until(absolute steady_clock deadline, [abort||shutdown])— drift-corrected, no QTimer coalescing. Queue dequeued underm_mu; gate callback fires outside the lock and is lock-free (atomicsetKeyDown); both waits predicated (spurious-wakeup safe). - Lifecycle:
unique_ptrownership;reset()joins the worker before AudioEngine teardown (commented), and the gate callback null-guardsm_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 synchronousspawnWorker=falseseam 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.
Summary
Fixes #3623 — the CWX local sidetone drops/clips individual CW elements under panadapter/VITA-49 load on Windows.
Root cause
CwxLocalKeyerscheduled every element edge with aQTimerparented toMainWindow, so the timer ran on the GUI event loop andkeyStateChangedwas 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
CwxLocalKeyerto mirrorIambicKeyer, which already abandonedQTimerfor exactly this reason ("QTimer's jitter is too high for CW"):std::chrono::steady_clockdeadline waited on an interruptiblecondition_variable— no QTimer, no event loop.onKeyDownChangecallback;CwSidetoneGenerator::setKeyDownisstd::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.MainWindownow owns the keyer as aunique_ptrand stops it (joining the worker) beforeAudioEngineis torn down, since the gate callback touchesm_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.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.
CwSidetoneGeneratoris 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 insideCwSidetoneGenerator::process()) remains available as a follow-up if sub-block edge precision is ever wanted.🤖 Generated with Claude Code