[cw] drift-correct local CWX sidetone keyer#3202
Conversation
…ethersdr#2980). Principle I. CwxLocalKeyer schedules each element by calling m_timer.start(durationMs) relative to "now" after every tick. On macOS the Cocoa run loop coalesces GUI-thread sources (panadapter paint, VITA-49 burst handling, status processing) and defers QTimer dispatch even with Qt::PreciseTimer set. Each deferral pushes the next edge later; the slip accumulates over the rest of the macro. At 20 WPM a dit is 60 ms, so 10–15 ms of jitter per tick rots the dit/dah cadence within seconds — audible as stutter and structurally wrong letter/word spacing. JS69135 confirmed via WebSDR that the radio's on-air signal sounds perfect: the air-side keyer (FlexLib's domain — Principle I, the radio is authoritative for protocol behavior) is fine, and the symptom is entirely AetherSDR's local sidetone. QSK-on exposes it because the operator's ear has the radio's fast-keyed monitor as a reference; QSK-off masks it because semi-break-in holds T/R continuously and small offsets become inaudible. Switch to drift-corrected absolute scheduling: track an QElapsedTimer from the start of a run, accumulate target edge times in m_nextEdgeMs, and set each m_timer.start() to the difference between target and elapsed. A late tick is followed by a correspondingly shorter wait, so the next edge lands on its absolute target instead of drifting later. When the run drains (both queue and elements empty) or stop() is called, m_elapsed is invalidated so the next start() begins a fresh epoch. Public API and signal shape unchanged — only private state added. Blast radius: risk_score=0.139, 1 high-risk affected (MainWindow::MainWindow constructs CwxLocalKeyer; recompile-only, no behavioral coupling — public surface and keyStateChanged signature are unchanged). Co-authored-by: Codex <noreply@openai.com>
There was a problem hiding this comment.
Reviewed the diff against main. The change is well-scoped to the two CwxLocalKeyer files and the implementation matches the description.
A few things I checked and was happy with:
- Reset paths are symmetric. Both
stop()and the final-drain branch inscheduleNext()invalidatem_elapsedand zerom_nextEdgeMs, so the next run starts from a fresh epoch. The partial-drain path (nextPendingalready queued) intentionally keeps the epoch alive — that's correct, since CWX text segments arriving back-to-back should stay drift-corrected against the same clock, and it also plays nicely with them_currentlyDownCharGap injection just above it. - Catch-up after a large stall. When a timeout is delivered very late, the next
waitclamps to 1 ms and elements fire back-to-back until the absolute timeline is caught up. That's the right behaviour for sidetone (the radio is the authority on what's on air) and matches the "long contest macro" scenario in the PR body. - Overflow.
m_nextEdgeMsisqint64,waitis clamped[1, m_nextEdgeMs]before theintcast — safe for any realistic CW run length. - No
AppSettings/ RAII / lifetime concerns for this change.
Nothing else from me — clean drift-correction patch and the inline comment at CwxLocalKeyer.cpp explaining why the absolute clock is in use is exactly the kind of context that's worth keeping in the source.
Thanks for the careful write-up of the manual-test methodology, and for noting the title-bar TX cancel-path follow-up rather than scope-creeping it into this PR.
73 and thanks @jensenpat 👍
🤖 aethersdr-agent · cost: $5.2470 · model: claude-opus-4-7
|
Claude here — merged, thanks @jensenpat. The absolute-clock approach is the right shape for this, and the manual-test methodology you documented (30 × S with aggressive panadapter drag) is exactly the kind of repro instructions that make this verifiable on any reviewer's hardware going forward. Filed a follow-up to add an automated drift-correction unit test so a future refactor of Title-bar 73, Jeremy KK7GWY & Claude (AI dev partner) |
…3644) ## 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` (#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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary This is a clean replacement PR for the CWX local sidetone timing fix from aethersdr#3109, rebased onto current `upstream/main`. - Replaces relative `QTimer::start(durationMs)` scheduling in `CwxLocalKeyer` with absolute, drift-corrected edge scheduling. - Tracks a `QElapsedTimer` epoch plus accumulated `m_nextEdgeMs` target so late GUI-thread timer dispatch is recovered on the following wait instead of accumulating. - Resets the timing epoch when CWX drains or is explicitly stopped, so each new run starts from a fresh clock. - Keeps the public API and `keyStateChanged(bool)` signal shape unchanged. ## Root Cause CWX local sidetone is driven from `CwxLocalKeyer`, which runs on the GUI thread and schedules each Morse element with `m_timer.start(durationMs)` relative to the time the previous timeout handler finally ran. On macOS, panadapter/waterfall paint, VITA-49 burst handling, status processing, and general Cocoa run-loop coalescing can delay `QTimer` delivery even with `Qt::PreciseTimer`. With relative scheduling, every delayed timeout pushes every later edge farther behind. Over a long CWX macro that slip becomes audible as local sidetone stutter and can stretch letter/word spacing. The radio's own CWX keyer remains authoritative for the on-air signal. This change only corrects AetherSDR's local sidetone cadence so it stays aligned with the intended CW timing under GUI load. ## Implementation Notes The scheduler now works like this: 1. Start an elapsed-time epoch on the first element of a run. 2. Accumulate the absolute target time for the next Morse edge in `m_nextEdgeMs`. 3. Start the timer for `target - elapsed`, clamped to at least 1 ms. 4. If a timeout arrives late, the next wait is shortened so the following edge returns to the intended timeline. 5. Invalidate the elapsed timer and zero the target when the run drains or `stop()` cancels it. ## Validation Built and tested from current `upstream/main` plus this patch on macOS. Automated checks run: - `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt -DCMAKE_OSX_SYSROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk` - `cmake --build build --parallel 22` - `build/cw_sidetone_test` passed - `build/iambic_keyer_test` passed - `QT_QPA_PLATFORM=offscreen build/cwx_panel_test` passed - `ctest --test-dir build --output-on-failure -j 22` reached 21/22 passing; `theme_manager_test` is failing on current latest-main local preference/theme-import state and is unrelated to the two-file `CwxLocalKeyer` change. Manual radio test performed by @jensenpat: - Created a CWX macro containing 30 `S` characters. - Watched each character's timing on the WAVE scope. - Aggressively dragged the panadapter during playback to create GUI-thread pressure. - Observed that character timing stayed lined up and the sidetone cadence sounded consistent throughout. The corrected build was also deployed to the remote macOS test machine at `patj@mac.jensencloud.net:/Users/patj/Desktop/AetherSDR.app` and quarantine was cleared. ## Reviewer Test Plan 1. Build AetherSDR on macOS with the pinned CommandLineTools SDK. 2. Connect to a radio in `CW` or `CWL`. 3. Enable CW sidetone / PC audio and set CW speed around 20-30 WPM. 4. Create a CWX macro with a repetitive pattern such as 30 `S` characters, or a long contest macro. 5. Send the macro and watch timing on WAVE scope or another audio/timing view. 6. While it sends, aggressively drag or resize the panadapter/waterfall to create GUI load. 7. Expected result: local sidetone remains even and character timing does not progressively slip. 8. Press `Esc` while CWX is active; expected result: CWX sends `cwx clear` and local sidetone stops. ## Follow-Up Note The title-bar `TX` cancel path forces MOX/CW/PTT down but does not currently clear the CWX local sidetone queue. `Esc` is the correct CWX abort path today. That is a separate UX/cancel-path follow-up from this timing drift fix. 👨🏼💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat Co-authored-by: aethersdr-agent[bot] <273844287+aethersdr-agent[bot]@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
Summary
This is a clean replacement PR for the CWX local sidetone timing fix from #3109, rebased onto current
upstream/main.QTimer::start(durationMs)scheduling inCwxLocalKeyerwith absolute, drift-corrected edge scheduling.QElapsedTimerepoch plus accumulatedm_nextEdgeMstarget so late GUI-thread timer dispatch is recovered on the following wait instead of accumulating.keyStateChanged(bool)signal shape unchanged.Root Cause
CWX local sidetone is driven from
CwxLocalKeyer, which runs on the GUI thread and schedules each Morse element withm_timer.start(durationMs)relative to the time the previous timeout handler finally ran.On macOS, panadapter/waterfall paint, VITA-49 burst handling, status processing, and general Cocoa run-loop coalescing can delay
QTimerdelivery even withQt::PreciseTimer. With relative scheduling, every delayed timeout pushes every later edge farther behind. Over a long CWX macro that slip becomes audible as local sidetone stutter and can stretch letter/word spacing.The radio's own CWX keyer remains authoritative for the on-air signal. This change only corrects AetherSDR's local sidetone cadence so it stays aligned with the intended CW timing under GUI load.
Implementation Notes
The scheduler now works like this:
m_nextEdgeMs.target - elapsed, clamped to at least 1 ms.stop()cancels it.Validation
Built and tested from current
upstream/mainplus this patch on macOS.Automated checks run:
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt -DCMAKE_OSX_SYSROOT=/Library/Developer/CommandLineTools/SDKs/MacOSX.sdkcmake --build build --parallel 22build/cw_sidetone_testpassedbuild/iambic_keyer_testpassedQT_QPA_PLATFORM=offscreen build/cwx_panel_testpassedctest --test-dir build --output-on-failure -j 22reached 21/22 passing;theme_manager_testis failing on current latest-main local preference/theme-import state and is unrelated to the two-fileCwxLocalKeyerchange.Manual radio test performed by @jensenpat:
Scharacters.The corrected build was also deployed to the remote macOS test machine at
patj@mac.jensencloud.net:/Users/patj/Desktop/AetherSDR.appand quarantine was cleared.Reviewer Test Plan
CWorCWL.Scharacters, or a long contest macro.Escwhile CWX is active; expected result: CWX sendscwx clearand local sidetone stops.Follow-Up Note
The title-bar
TXcancel path forces MOX/CW/PTT down but does not currently clear the CWX local sidetone queue.Escis the correct CWX abort path today. That is a separate UX/cancel-path follow-up from this timing drift fix.👨🏼💻 Generated with OpenAI Codex (GPT-5.5 Pro 4/23) and tested by @jensenpat