Skip to content

[cw] drift-correct local CWX sidetone keyer#3202

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/cwx-local-sidetone-drift
May 27, 2026
Merged

[cw] drift-correct local CWX sidetone keyer#3202
ten9876 merged 1 commit into
aethersdr:mainfrom
jensenpat:aether/cwx-local-sidetone-drift

Conversation

@jensenpat

Copy link
Copy Markdown
Collaborator

Summary

This is a clean replacement PR for the CWX local sidetone timing fix from #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

…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>
@jensenpat jensenpat changed the title fix(cwx): drift-correct local sidetone keyer [cw] drift-correct local CWX sidetone keyer May 27, 2026
@jensenpat jensenpat marked this pull request as ready for review May 27, 2026 02:05
@jensenpat jensenpat requested a review from a team as a code owner May 27, 2026 02:05

@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.

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 in scheduleNext() invalidate m_elapsed and zero m_nextEdgeMs, so the next run starts from a fresh epoch. The partial-drain path (next Pending already 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 the m_currentlyDown CharGap injection just above it.
  • Catch-up after a large stall. When a timeout is delivered very late, the next wait clamps 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_nextEdgeMs is qint64, wait is clamped [1, m_nextEdgeMs] before the int cast — 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

@ten9876 ten9876 merged commit d315a78 into aethersdr:main May 27, 2026
5 checks passed
@ten9876

ten9876 commented May 27, 2026

Copy link
Copy Markdown
Collaborator

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 scheduleNext doesn't quietly regress this: see the linked issue. Not blocking on anyone — just keeping the safety-net thread alive.

Title-bar TX cancel-path → CWX clear gap you flagged at the bottom of the PR body is worth its own issue too whenever someone wants to take that on; it doesn't need to live in your queue.

73, Jeremy KK7GWY & Claude (AI dev partner)

ten9876 pushed a commit that referenced this pull request Jun 19, 2026
…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>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
## 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>
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: Saved Messages stuttering and timing spacing incorrect on macOS (CW timing issues)

2 participants